site: the landing page, on things bench really does

Turn design 1b (Dockside) into `/`: a terminal hero, the claim README.md
already makes, six doors, and a strip at the foot. The layout is task
31's; what this commit is really about is that nothing factual on the
page is typed by hand.

- **Two facts are read, not written.** `site/build.py` reads the install
  one-liner out of README.md's "Install into a repo" block and the
  version out of `manager/core/VERSION`, and offers them to the template
  as `$install_block` and `$version`. A renamed section, a missing
  VERSION or an install section that lost its command block stops the
  build, exactly as a renamed heading already did. `pages.json` loses
  its `version` key, and a build refuses one if it comes back.

- **A dead internal link stops the build.** Every href a rendered page
  emits — a door as much as a link inside a slice — must resolve to a
  route in the manifest or a file in `static/`/`root/`. The check runs
  after rendering and before writing, so a bad link leaves the last good
  build standing rather than shipping a 404 with a nice typeface.

- **Six real doors.** `pages.json` grows the routes they open: install
  and first run, the five stages, agents on the board, PRs and review,
  team mode, the three-layer law. They are heading slices, i.e. the
  stub routes task 33 expected and task 34 will re-cut.

- **The terminal is a transcript.** README.md's own command, then lines
  install.py and board.py really print, with the abridgement declared in
  the terminal's title bar. `tests/test_site_landing.py` holds every one
  of those lines against the source that prints it, so a reworded prompt
  fails the suite instead of quietly making the page fiction.

- **No fake telemetry.** Turn 1's "most opened this week" strip becomes
  the version, read from VERSION, and a link to the releases.

The landing page no longer carries a generated body, so the tests that
read one from it now read `/concepts/stages/`, and the two scratch repos
in the suite copy VERSION alongside the markdown.

python3 -m unittest: 407 tests, OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-31 12:10:25 +02:00
co-authored by Claude Opus 5
parent 68ee681c18
commit 274ad6b2c6
8 changed files with 718 additions and 99 deletions
+14
View File
@@ -11,6 +11,13 @@ naming the route and the heading it can no longer find. A documented
behaviour and the behaviour itself cannot drift apart when one is cut
from the other.
The landing page is the exception that proves it. Its words are authored
in `templates/home.html` (`"source": null`), so the two facts it must
never get subtly wrong are not written there at all: the install
one-liner is read out of `README.md`'s "Install into a repo" block and
the version out of `manager/core/VERSION`, and the template is offered
them as `$install_block` and `$version`.
```bash
python3 -m pip install -r site/requirements.txt # once
python3 site/fetch-fonts.py # once, needs network
@@ -168,6 +175,13 @@ Each of these exits non-zero with a message naming the route:
- a slice that comes out empty;
- a markdown link to a repo path that does not exist, or that escapes
the repo — a dead relative link must never reach the site;
- an internal link on any rendered page — a door on the landing page as
much as a link inside a slice — that resolves to nothing this build
writes;
- a missing or empty `manager/core/VERSION`, or a `README.md` whose
"Install into a repo" section has lost its command block;
- a `version` key in `pages.json`, which would be a second copy of a
number that has one home;
- a template placeholder the builder does not supply;
- a file in `root/` that a route would also write.
+161 -3
View File
@@ -29,6 +29,17 @@ Templates are `string.Template`, so placeholders are `$name` and a literal
dollar is `$$` — `str.format` was not an option with a stylesheet's worth
of braces in play.
## What an authored page is still not allowed to type
The landing page's words are authored in its template rather than sliced,
which would be a hole in the promise above if it extended to facts. It
does not: the install one-liner and the version reach the template as
`$install_block` and `$version`, read from `README.md` and
`manager/core/VERSION` (see `repo_facts`). And every internal link any
page emits — a door on the landing page as much as a link inside a slice
— must resolve to something this build writes, or the build stops
(`check_links`).
## What the host needs from the build
Two things here exist for the way the site is served (site/wrangler.jsonc,
@@ -75,8 +86,19 @@ STAMPED = {"stylesheet": "static/site.css", "icon": "static/favicon.svg"}
ATX = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$")
FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
CSS_URL = re.compile(r"""url\(\s*["']?([^"')]+)["']?\s*\)""")
LINK = re.compile(r"""(?:href|src)=["']([^"']+)["']""", re.IGNORECASE)
COMMENT = re.compile(r"\s{2,}#")
EXTERNAL = ("http://", "https://", "//", "mailto:", "tel:", "data:")
# The two facts the landing page must never carry a hand-typed copy of,
# and the files that own them. A subtly wrong `curl` is worse than no
# landing page at all, and a version the release does not have is a
# support question; both are read, and a source that moved stops the
# build exactly as a renamed heading does.
VERSION_FILE = "manager/core/VERSION"
INSTALL_SOURCE = "README.md"
INSTALL_HEADING = "## Install into a repo"
class BuildError(Exception):
"""A failure a person must read and fix: a missing source, a renamed
@@ -172,6 +194,91 @@ def slice_section(text: str, page: dict, source: str) -> str:
return promote(body, level - 1)
# ── facts read out of the repo ────────────────────────────────────────
def read_version(repo: Path) -> str:
"""`manager/core/VERSION`, which is the version `release.sh` tags."""
path = repo / VERSION_FILE
if not path.is_file():
raise BuildError(
f"the site shows the version from {VERSION_FILE}, which does "
f"not exist. It is the one place the version is written; the "
f"site does not keep a second copy.")
version = path.read_text(encoding="utf-8").strip()
if not version:
raise BuildError(f"{VERSION_FILE} is empty.")
return version
def install_command(repo: Path) -> list:
"""The lines of README.md's install one-liner, verbatim.
It is the command people paste, so the landing page reads it out of
the file that documents it rather than transcribing it. Editing the
README moves the page; renaming the section stops the build."""
path = repo / INSTALL_SOURCE
if not path.is_file():
raise BuildError(
f"the install command is read out of {INSTALL_SOURCE}, which "
f"does not exist.")
text = path.read_text(encoding="utf-8")
marks = list(headings(text))
start = find_heading(marks, INSTALL_HEADING)
if start is None:
raise BuildError(
f'{INSTALL_SOURCE} has no heading "{INSTALL_HEADING}" — the '
f"landing page reads the install command from that section. "
f"Fix the heading, or INSTALL_HEADING in site/build.py.")
start_line, level = start
following = [i for i, found_level, _ in marks
if i > start_line and found_level <= level]
end_line = following[0] if following else len(text.splitlines())
block, fence = [], None
for line in text.splitlines()[start_line + 1:end_line]:
opener = FENCE.match(line)
if opener and fence is None:
fence = opener.group(1)
continue
if opener:
break
if fence is not None:
block.append(line)
if not block:
raise BuildError(
f'{INSTALL_SOURCE}: the "{INSTALL_HEADING}" section has no '
f"fenced command block. The landing page's terminal has "
f"nothing to show.")
return block
def render_command(lines: list) -> str:
"""A command block as terminal html: every line escaped and otherwise
verbatim, a prompt on the lines that begin a command — a line after
one ending in a backslash is a continuation, not a new command — and
a dim tail for a trailing comment."""
out, continued = [], False
for line in lines:
prompt = "" if continued else '<span class="t-calm">$</span> '
continued = line.rstrip().endswith("\\")
found = COMMENT.search(line)
body, tail = (line[:found.start()], line[found.start():]) if found \
else (line, "")
html = escape(body)
if tail:
html += f'<span class="t-dim">{escape(tail)}</span>'
out.append(prompt + html)
return "\n".join(out)
def repo_facts(repo: Path) -> dict:
"""What the templates are offered instead of typing it themselves."""
return {
"version": read_version(repo),
"install_block": render_command(install_command(repo)),
}
# ── links ─────────────────────────────────────────────────────────────
def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
@@ -201,6 +308,42 @@ def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
return blob + rel + hash_mark + fragment
def internal_targets(manifest: dict, site: Path) -> set:
"""Every url the built site answers: one per route (and the file that
route is written to), plus everything copied verbatim out of static/
and root/."""
urls = set()
for page in manifest["pages"]:
urls.add(page["path"])
urls.add("/" + target_for(Path(), page["path"]).as_posix())
for base, prefix in ((site / "static", "/static/"), (site / ROOT, "/")):
if base.is_dir():
urls.update(prefix + path.relative_to(base).as_posix()
for path in base.rglob("*") if path.is_file())
return urls
def check_links(html: str, page: dict, targets: set) -> None:
"""A link on a rendered page that the build does not write is a 404
with a nice typeface. Markdown links out of a source file are already
resolved (rewrite_link); this is the other half — what the templates
themselves point at, which is where the landing page's doors live."""
for url in LINK.findall(html):
if not url or url.startswith("#") or url.startswith(EXTERNAL):
continue
if not url.startswith("/"):
raise BuildError(
f'{page["path"]}: links to "{url}". A page is written to '
f"its own directory, so a relative link resolves against "
f"that; write internal links from the root.")
if url.split("#")[0].split("?")[0] not in targets:
raise BuildError(
f'{page["path"]}: links to "{url}", which this build does '
f"not write. Every internal link must resolve to a route "
f"in site/pages.json or a file in site/static/ or "
f"site/{ROOT}/.")
# ── rendering ─────────────────────────────────────────────────────────
def slugify(text: str) -> str:
@@ -349,7 +492,7 @@ def stamp(site: Path) -> dict:
def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
stamps: dict = None) -> str:
stamps: dict = None, facts: dict = None) -> str:
source = page.get("source")
if source:
body, contents = render_markdown(
@@ -363,6 +506,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
config = manifest["site"]
blob = config["blob_base"].rstrip("/") + "/"
stamps = stamps if stamps is not None else stamp(site)
facts = facts if facts is not None else repo_facts(repo)
fields = {
"stylesheet": stamps["stylesheet"],
"icon": stamps["icon"],
@@ -371,7 +515,8 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
or config.get("description", "")),
"site_title": escape(config["title"]),
"site_tagline": escape(config.get("tagline", "")),
"version": escape(config.get("version", "")),
"version": escape(facts["version"]),
"install_block": facts["install_block"],
"body": body,
"toc": render_contents(contents),
"nav": render_nav(manifest, page),
@@ -380,6 +525,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
"section": escape(page.get("section") or ""),
"repo_url": config["repo_url"],
"issues_url": config.get("issues_url", config["repo_url"]),
"releases_url": config.get("releases_url", config["repo_url"]),
"source_url": (blob + source) if source else config["repo_url"],
"source_path": escape(source or ""),
"canonical": config.get("base_url", "").rstrip("/") + page["path"],
@@ -414,6 +560,11 @@ def load_manifest(site: Path) -> dict:
for key in ("title", "repo_url", "blob_base"):
if key not in manifest["site"]:
raise BuildError(f'pages.json: site has no "{key}" key')
if "version" in manifest["site"]:
raise BuildError(
f'pages.json: site has a "version" key, but the version the '
f"site shows is read from {VERSION_FILE}. Two copies of a "
f"version is one copy too many — remove it.")
seen = set()
for page in manifest["pages"]:
@@ -526,6 +677,7 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
out = out or (site / "dist")
manifest = load_manifest(site)
stamps = stamp(site)
facts = repo_facts(repo)
# A file in root/ that a route also claims would be silently replaced
# by whichever is written last, so it is a build failure instead.
@@ -539,9 +691,15 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
f"{relative}. Rename one — the build will not pick a winner.")
pages = [(page, render_page(page, manifest, site=site, repo=repo,
stamps=stamps))
stamps=stamps, facts=facts))
for page in manifest["pages"]]
# After rendering, before writing: a dead internal link leaves the
# last good build standing, exactly as a renamed heading does.
targets = internal_targets(manifest, site)
for page, html in pages:
check_links(html, page, targets)
clear(out)
out.mkdir(parents=True)
(out / MARKER).write_text(
+61 -3
View File
@@ -2,11 +2,11 @@
"site": {
"title": "bench",
"tagline": "docs",
"version": "0.2-alpha",
"description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
"base_url": "https://bench.12vectors.com",
"repo_url": "https://github.com/12vectors/bench",
"issues_url": "https://github.com/12vectors/bench/issues",
"releases_url": "https://github.com/12vectors/bench/releases",
"blob_base": "https://github.com/12vectors/bench/blob/main/"
},
@@ -15,10 +15,28 @@
"pages": [
{
"path": "/",
"title": "bench",
"title": "bench — a live kanban for coding-agent work",
"layout": "home",
"section": null,
"description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
"description": "Task files in stage directories are the only source of truth. A stdlib-only board narrates what happens to them — agents working in git worktrees, PRs opening on review, CI on the cards — and never merges anything itself.",
"source": null
},
{
"path": "/guides/install/",
"title": "Install and first run",
"layout": "article",
"section": "Guides",
"description": "Untar bench into .task-manager/, run start.sh, and answer the three questions the first run asks.",
"source": "README.md",
"from": "## Install into a repo",
"to": "## Update"
},
{
"path": "/concepts/stages/",
"title": "The five stages",
"layout": "article",
"section": "Concepts",
"description": "backlog, to-do, in-progress, review, done — the directory a task file sits in is its status, and there is no other source of truth.",
"source": "AGENTS.md",
"from": "## Stages",
"to": "## Moving a task"
@@ -33,6 +51,46 @@
"from": "## Claiming a card",
"to": "## Syncing boards"
},
{
"path": "/concepts/agents-on-the-board/",
"title": "Agents on the board",
"layout": "article",
"section": "Concepts",
"description": "Start work makes a worktree and a branch, runs the agent headless, and moves the card on the way it exits.",
"source": "AGENTS.md",
"from": "## Agents working the board",
"to": "## Pull requests"
},
{
"path": "/concepts/pull-requests/",
"title": "PRs and review",
"layout": "article",
"section": "Concepts",
"description": "A card entering review gets a PR opened for it by the board; then review PR, copilot and act on PR, until it settles.",
"source": "AGENTS.md",
"from": "## Pull requests",
"to": "## Stages"
},
{
"path": "/concepts/team-mode/",
"title": "Team mode",
"layout": "article",
"section": "Concepts",
"description": "BOARD_SYNC makes origin/main the truth and every board a converging replica: moves commit and push themselves, and a beat pulls what the other boards published.",
"source": "AGENTS.md",
"from": "## Syncing boards",
"to": "## Task file format"
},
{
"path": "/concepts/three-layer-law/",
"title": "The three-layer law",
"layout": "article",
"section": "Concepts",
"description": "Core knows tasks, worktrees, PRs and events. Drivers know apps, adapters know agent vendors, local/ knows your project.",
"source": "README.md",
"from": "## The three-layer law",
"to": "## License"
},
{
"path": "/404.html",
"title": "Not found",
+34 -13
View File
@@ -248,26 +248,25 @@ a:hover{color:var(--text);text-decoration:underline}
.dot-alarm{background:var(--ink-alarm)}
.dot-calm{background:var(--ink-calm)}
.terminal-title{font-size:11px;color:var(--ink-dim);margin-left:6px}
/* The transcript below is real output with the long stretches cut, and
the bar is where it says so a claim about the block, not decoration. */
.terminal-note{font-size:10.5px;color:var(--ink-dim);letter-spacing:.04em}
.terminal-body{
margin:0;padding:18px;background:var(--ink);color:var(--ink-text);
font:var(--t-code)/1.85 var(--mono);
white-space:pre-wrap;word-break:break-all;
/* The release url is 72 characters and has to wrap rather than clip;
`anywhere` breaks it, and breaks a word only when a word is what
does not fit the transcript's prose lines wrap normally. */
white-space:pre-wrap;overflow-wrap:anywhere;
}
.t-calm{color:var(--ink-calm)}
.t-accent{color:var(--ink-accent)}
.t-muted{color:var(--ink-muted)}
.t-dim{color:var(--ink-dim)}
.generated{padding:0 44px 34px}
.page-home .prose{
background:var(--surface);border:1px solid var(--border-soft);
border-radius:12px;padding:28px 32px 12px;
}
.generated-heading{margin-top:0 !important}
.generated-note{
margin:0 0 20px;font-size:11px;color:var(--dim);max-width:none;
}
/* Every door is a link to a page this site builds the whole card is
the target, so the hover is the card's border rather than an
underline under three words of it. */
.doors{
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
padding:0 44px 34px;
@@ -275,9 +274,11 @@ a:hover{color:var(--text);text-decoration:underline}
.door{
display:flex;flex-direction:column;gap:8px;padding:20px;
background:var(--surface);border:1px solid var(--border-soft);
border-radius:12px;
border-radius:12px;color:var(--text);
}
.door:hover{border-color:var(--accent);color:var(--text);text-decoration:none}
.door-dark{background:var(--text);border-color:var(--text);color:var(--canvas)}
.door-dark:hover{border-color:var(--accent);color:var(--canvas)}
.door-index{font-size:11px;color:var(--dim)}
.door-dark .door-index{color:#7fa0a8}
.door-title{font:600 19px/1.2 var(--display)}
@@ -285,6 +286,26 @@ a:hover{color:var(--text);text-decoration:underline}
.door-dark .door-text{color:#a9c4c9}
.door .mono{font-size:var(--t-code)}
/* The strip at the foot of the landing page. It carries the version and
nothing else measurable, because the version is the only thing about
itself this site can measure. */
.strip{
display:flex;align-items:center;gap:14px;flex-wrap:wrap;
padding:18px 44px;background:var(--sunken);
border-top:1px solid var(--border-soft);
}
.strip-label{
font-size:var(--t-micro);letter-spacing:.1em;text-transform:uppercase;
color:var(--dim);
}
.strip-version{
font-size:var(--t-code);padding:4px 10px;background:var(--surface);
border:1px solid var(--border);border-radius:99px;color:var(--accent);
}
.strip-text{font-size:12.5px;line-height:1.55;color:var(--muted);max-width:64ch}
.strip-text .mono{font-size:var(--t-code)}
.strip-link{font-size:12px;color:var(--accent)}
/* ── 404 (layout "notfound") ── */
.lost{
display:flex;flex-direction:column;align-items:flex-start;gap:18px;
@@ -304,7 +325,7 @@ a:hover{color:var(--text);text-decoration:underline}
.gutter{display:none}
.hero{grid-template-columns:1fr;gap:28px;padding:32px 24px}
.doors{grid-template-columns:repeat(2,1fr);padding:0 24px 28px}
.generated{padding:0 24px 28px}
.strip{padding:18px 24px}
}
@media (max-width:760px){
.shell{grid-template-columns:minmax(0,1fr)}
+68 -41
View File
@@ -2,20 +2,37 @@
<!-- Layout "home" — 1b Dockside from the turn-2 docs design: a landing
that is mostly a terminal and a set of doors.
The hero copy and the doors are authored here on purpose; task 33
owns the landing page's words. Everything under "The five stages"
is generated from the source named in site/pages.json.
The words here are authored rather than sliced (site/pages.json says
so with "source": null), which is exactly why the two facts a
landing page must never get subtly wrong are not written here at
all: $install_block is README.md's own install command and $version
is manager/core/VERSION, both read by site/build.py.
The terminal is abridged — it says so in its own title bar — but
nothing in it is composed. Every line is one a first run really
prints: install.py's setup questions and its closing line,
board.py's startup. tests/test_site_landing.py holds each of them
against the source that prints it, so a reworded prompt fails the
suite rather than quietly making this page fiction.
Placeholders are string.Template's, so every literal dollar in the
markup is doubled — which is why the shell prompts in the terminal
below look doubled in the source and single on the page. -->
markup would have to be doubled. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$site_title · $site_tagline</title>
<title>$title</title>
<meta name="description" content="$description">
<link rel="canonical" href="$canonical">
<!-- This is the url that gets pasted into a chat, so it carries its own
card. No og:image: the site has no raster art, and an empty one is
worse than none. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="$site_title">
<meta property="og:title" content="$title">
<meta property="og:description" content="$description">
<meta property="og:url" content="$canonical">
<meta name="twitter:card" content="summary">
<link rel="icon" href="$icon">
<link rel="stylesheet" href="$stylesheet">
</head>
@@ -26,7 +43,7 @@
<span class="topbar-sep">/</span>
<span class="topbar-repo">bench</span>
<span class="spacer"></span>
<span>$version</span>
<span>docs for $version</span>
</div>
<header class="masthead masthead-flat">
@@ -50,8 +67,8 @@ $nav
working in git worktrees, PRs opening on review, CI on the cards —
and never merges anything itself.</p>
<div class="hero-actions">
<a class="button button-solid button-lg" href="#stages">Read the stages</a>
<a class="button button-lg" href="$repo_url#install-into-a-repo">Install into a repo</a>
<a class="button button-solid button-lg" href="/guides/install/">Install into a repo</a>
<a class="button button-lg" href="$repo_url">bench on GitHub ↗</a>
</div>
<span class="mono aside-note">no account, no service, no database. it is
python 3 on your own machine.</span>
@@ -62,72 +79,82 @@ $nav
<span class="dot dot-alarm"></span>
<span class="dot dot-calm"></span>
<span class="mono terminal-title">~/your-repo</span>
<span class="spacer"></span>
<span class="mono terminal-note">first run · abridged</span>
</div>
<pre class="terminal-body"><span class="t-calm">$$</span> mkdir .task-manager &amp;&amp; curl -L \
<span class="t-dim">https://github.com/12vectors/bench/releases/latest/download/bench.tar.gz</span> \
| tar -xz -C .task-manager
<span class="t-calm">$$</span> ./.task-manager/start.sh
<pre class="terminal-body">$install_block
<span class="t-muted">No .task-manager/manager/local/.env yet — a few questions and bench writes one.</span>
solo or team? <span class="t-accent">[solo]</span>:
which agent adapter? <span class="t-accent">[claude]</span>:
what command runs this project's tests? <span class="t-accent">[python3 -m unittest]</span>:
No .task-manager/manager/local/.env yet — a few questions and bench writes one.
Enter takes the default in [brackets]; Ctrl-D skips the rest.
Task board for ~/your-repo/.task-manager/tasks
<span class="t-accent">http://127.0.0.1:26071/</span>
<span class="t-dim">Ctrl-C to stop</span></pre>
</div>
</section>
solo or team? <span class="t-accent">[solo]</span>:
which agent adapter? <span class="t-accent">[claude]</span>:
what command runs this project's tests? <span class="t-accent">[python3 -m unittest]</span>:
<section class="generated" id="stages">
<div class="prose">
<h2 class="generated-heading">The five stages</h2>
<p class="generated-note mono">from <span>$source_path</span> — the
section below is this file's, not a retelling of it</p>
$body
Wrote .task-manager/manager/local/.env — every other setting is in there, commented; edit it any time.
<span class="t-dim"></span>
Task board for ~/your-repo/.task-manager/tasks
<span class="t-accent">http://127.0.0.1:26071/</span>
<span class="t-dim">Ctrl-C to stop</span></pre>
</div>
</section>
<section class="doors">
<div class="door">
<a class="door" href="/guides/install/">
<span class="mono door-index">01</span>
<span class="door-title">Install</span>
<span class="door-title">Install and first run</span>
<span class="door-text">Untar it into <span class="mono">.task-manager/</span>,
run <span class="mono">start.sh</span>, answer three questions.
Port 26071, pinned.</span>
</div>
<div class="door">
</a>
<a class="door" href="/concepts/stages/">
<span class="mono door-index">02</span>
<span class="door-title">The five stages</span>
<span class="door-text">backlog → to-do → in-progress → review → done.
The directory a file sits in <i>is</i> its status.</span>
</div>
<div class="door">
</a>
<a class="door" href="/concepts/agents-on-the-board/">
<span class="mono door-index">03</span>
<span class="door-title">Agents on the board</span>
<span class="door-text">▸ start work makes a worktree and a branch, runs
the agent headless, and moves the card when it exits.</span>
</div>
<div class="door">
</a>
<a class="door" href="/concepts/pull-requests/">
<span class="mono door-index">04</span>
<span class="door-title">PRs &amp; review</span>
<span class="door-text">A card entering review gets a PR opened for it.
Then ◔ review PR, ⚑ copilot, ↻ act on PR — until it settles.</span>
</div>
<div class="door">
</a>
<a class="door" href="/concepts/team-mode/">
<span class="mono door-index">05</span>
<span class="door-title">Team mode</span>
<span class="door-text"><span class="mono">BOARD_SYNC=1</span> makes
origin/main the truth. Moves commit and push themselves; boards pull
on a beat.</span>
</div>
<div class="door door-dark">
</a>
<a class="door door-dark" href="/concepts/three-layer-law/">
<span class="mono door-index">06</span>
<span class="door-title">The three-layer law</span>
<span class="door-text">Core knows tasks, worktrees, PRs and events.
Drivers know apps, adapters know vendors, local/ knows your
project.</span>
</div>
</a>
</section>
<!-- The strip turn 1 of the design filled with a most-read ranking. This
site counts nothing and never will, so it says the one thing about
itself it can prove: which version it was built from. -->
<section class="strip">
<span class="mono strip-label">Version</span>
<span class="mono strip-version">$version</span>
<span class="strip-text">read from <span class="mono">manager/core/VERSION</span>
when this page was built. <span class="mono">update.sh</span> replaces an
install with the latest release; <span class="mono">BENCH_REF=v2</span>
pins an exact one.</span>
<span class="spacer"></span>
<a class="mono strip-link" href="$releases_url">Releases ↗</a>
</section>
<footer class="footer">
+60 -38
View File
@@ -36,9 +36,14 @@ def builder():
BUILDER = builder()
# The sources every page is cut from. A scratch repo needs these and
# nothing else to build the real manifest.
SOURCES = ["AGENTS.md", "README.md"]
# The files a build reads out of the repo: the two the pages are cut
# from, and the one the version is read from. A scratch repo needs these
# and nothing else to build the real manifest.
SOURCES = ["AGENTS.md", "README.md", "manager/core/VERSION"]
# A layout with no markup of its own, written into a scratch site when a
# test wants to exercise the builder rather than a shipped template.
PLAIN = "<!doctype html>\n<title>$title</title>\n$body\n"
try:
import markdown_it # noqa: F401
@@ -69,12 +74,28 @@ class ScratchRepo:
shutil.copytree(SITE, root / "site",
ignore=shutil.ignore_patterns("dist", "__pycache__"))
for name in SOURCES:
(root / name).parent.mkdir(parents=True, exist_ok=True)
shutil.copy(REPO / name, root / name)
@property
def out(self) -> Path:
return self.root / "site" / "dist"
def plain_home(self) -> dict:
"""A landing page with no markup, so a manifest cut down to one
page under test still answers the `/` every layout's wordmark
links to which the build now checks."""
(self.root / "site" / "templates" / "plain.html").write_text(
PLAIN, encoding="utf-8")
return {"path": "/", "title": "Home", "layout": "plain",
"section": None, "source": None}
def pages(self, *entries: dict) -> None:
"""The manifest reduced to these pages, plus that landing page."""
manifest = self.manifest()
manifest["pages"] = [self.plain_home(), *entries]
self.write_manifest(manifest)
def manifest(self) -> dict:
return json.loads(
(self.root / "site" / "pages.json").read_text(encoding="utf-8"))
@@ -136,15 +157,17 @@ class TheRealSiteBuilds(unittest.TestCase):
self.assertTrue((self.out / "static" / "site.css").is_file())
self.assertTrue((self.out / "static" / "favicon.svg").is_file())
def test_both_layouts_render_real_content_from_agents_md(self):
"""Not lorem: the words on the page are the words in the file."""
home = self.page("/")
def test_the_article_layout_renders_real_content_from_agents_md(self):
"""Not lorem: the words on the page are the words in the file.
(The home layout is authored rather than sliced its own facts
are tests/test_site_landing.py's subject.)"""
stages = self.page("/concepts/stages/")
article = self.page("/concepts/claiming-a-card/")
# Sliced out of AGENTS.md's "## Stages" by the home entry.
self.assertIn("The directory a file sits in", home)
# Sliced out of AGENTS.md's "## Stages".
self.assertIn("Most tasks live here for most of their life", stages)
self.assertIn("a stale <code>in-progress/</code> makes the board",
home)
# ...and out of "## Claiming a card" by the article entry.
stages)
# ...and out of "## Claiming a card".
self.assertIn("The first claim sticks", article)
self.assertIn("Identity is git's, so it collides like git's",
article)
@@ -159,9 +182,9 @@ class TheRealSiteBuilds(unittest.TestCase):
def test_sub_headings_are_promoted_to_the_pages_own_level(self):
"""AGENTS.md's `### backlog/` under `## Stages` becomes an <h2>
with an anchor, so the layout's contents list can reach it."""
home = self.page("/")
self.assertIn('<h2 id="backlog">backlog/</h2>', home)
self.assertIn('<h2 id="in-progress">in-progress/</h2>', home)
stages = self.page("/concepts/stages/")
self.assertIn('<h2 id="backlog">backlog/</h2>', stages)
self.assertIn('<h2 id="in-progress">in-progress/</h2>', stages)
def test_the_ia_comes_out_of_the_manifest(self):
article = self.page("/concepts/claiming-a-card/")
@@ -276,13 +299,11 @@ class DriftStopsTheBuild(ScratchCase):
def test_a_section_emptied_to_its_heading_fails(self):
"""The subtler drift: the heading survives, its content moves
elsewhere. An empty page is a drift, not a page."""
manifest = self.repo.manifest()
manifest["pages"] = [{
self.repo.pages({
"path": "/hollow/", "title": "Hollow", "layout": "article",
"section": "Concepts", "source": "AGENTS.md",
"from": "## Empty", "to": "## After",
}]
self.repo.write_manifest(manifest)
})
self.repo.edit("AGENTS.md", "## Claiming a card",
"## Empty\n\n## After\n\n## Claiming a card")
result = self.repo.build()
@@ -293,13 +314,11 @@ class DriftStopsTheBuild(ScratchCase):
def test_a_heading_inside_a_code_fence_is_not_a_heading(self):
"""AGENTS.md fences a task file template starting `# Task title`.
Matching that would slice the document in half."""
manifest = self.repo.manifest()
manifest["pages"] = [{
self.repo.pages({
"path": "/fenced/", "title": "Fenced", "layout": "article",
"section": "Concepts", "source": "AGENTS.md",
"from": "# Task title",
}]
self.repo.write_manifest(manifest)
})
result = self.repo.build()
self.assertNotEqual(result.returncode, 0)
self.assertIn("# Task title", result.stderr)
@@ -312,13 +331,13 @@ class LinksComeOutWorking(ScratchCase):
def build_one(self, body: str, *, link_routes=None):
(self.repo.root / "SOURCE.md").write_text(
f"# Doc\n\n## Section\n\n{body}\n", encoding="utf-8")
manifest = self.repo.manifest()
manifest["link_routes"] = link_routes or {}
manifest["pages"] = [{
"path": "/linked/", "title": "Linked", "layout": "article",
self.repo.pages({
"path": "/linked/", "title": "Linked", "layout": "plain",
"section": "Concepts", "source": "SOURCE.md",
"from": "## Section",
}]
})
manifest = self.repo.manifest()
manifest["link_routes"] = link_routes or {}
self.repo.write_manifest(manifest)
result = self.repo.build()
page = self.repo.out / "linked" / "index.html"
@@ -334,9 +353,9 @@ class LinksComeOutWorking(ScratchCase):
def test_link_routes_win_over_github(self):
result, html = self.build_one(
"See [the brief](AGENTS.md#stages).",
link_routes={"AGENTS.md": "/concepts/stages/"})
link_routes={"AGENTS.md": "/linked/"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('href="/concepts/stages/#stages"', html)
self.assertIn('href="/linked/#stages"', html)
def test_an_absolute_link_and_an_anchor_are_left_alone(self):
result, html = self.build_one(
@@ -364,14 +383,12 @@ class TheArticleGutterFollowsTheBody(ScratchCase):
contents entry without anyone editing the site."""
def test_promoted_sub_headings_become_the_contents_list(self):
manifest = self.repo.manifest()
manifest["pages"] = [{
self.repo.pages({
"path": "/concepts/team-mode/", "title": "Syncing boards",
"layout": "article", "section": "Concepts",
"source": "AGENTS.md", "from": "## Syncing boards",
"to": "## Task file format",
}]
self.repo.write_manifest(manifest)
})
result = self.repo.build()
self.assertEqual(result.returncode, 0, result.stderr)
@@ -395,6 +412,10 @@ class TheManifestIsChecked(ScratchCase):
self.repo.write_manifest(manifest)
return self.repo.build()
def with_landing(self, page: dict):
self.repo.pages(page)
return self.repo.build()
def test_an_unknown_layout_lists_the_ones_that_exist(self):
result = self.only({
"path": "/x/", "title": "X", "layout": "logbook",
@@ -412,13 +433,14 @@ class TheManifestIsChecked(ScratchCase):
self.assertIn("from", result.stderr)
def test_an_authored_page_says_so_with_a_null_source(self):
"""The landing page 33 will write has no slice and no drift."""
result = self.only({
"path": "/", "title": "bench", "layout": "home",
"source": None})
"""An authored page has no slice and so no drift: the builder
offers it an empty $body and renders the template's own words."""
result = self.with_landing({
"path": "/authored/", "title": "Authored", "layout": "plain",
"section": None, "source": None})
self.assertEqual(result.returncode, 0, result.stderr)
html = (self.repo.out / "index.html").read_text("utf-8")
self.assertIn("Put the agents", html)
html = (self.repo.out / "authored" / "index.html").read_text("utf-8")
self.assertIn("<title>Authored</title>", html)
def test_a_route_must_be_a_directory_path(self):
result = self.only({
+4 -1
View File
@@ -318,7 +318,10 @@ class Scratch(unittest.TestCase):
self.addCleanup(shutil.rmtree, self.root, True)
shutil.copytree(SITE, self.root / "site",
ignore=shutil.ignore_patterns("dist", "__pycache__"))
for name in ("AGENTS.md", "README.md"):
# The markdown the pages are cut from, plus the file the version
# shown on them is read from.
for name in ("AGENTS.md", "README.md", "manager/core/VERSION"):
(self.root / name).parent.mkdir(parents=True, exist_ok=True)
shutil.copy(REPO / name, self.root / name)
self.out = self.root / "site" / "dist"
+316
View File
@@ -0,0 +1,316 @@
"""The landing page is the one page most visitors read, and the only one
whose words are authored rather than cut from a heading. That is the risk
this file is about.
Three promises, each mechanised below:
- **The facts on it are read, not typed.** The install one-liner comes out
of README.md and the version out of manager/core/VERSION, so changing
either source changes the page and losing either stops the build.
- **The terminal is a transcript, not a mood.** Every line it shows is
held against the source that prints it install.py's setup questions,
board.py's startup. A reworded prompt fails here rather than quietly
turning the strongest thing on the page into fiction.
- **Every door opens.** The six cards point at routes this build writes,
and a link that resolves to nothing fails the build instead of shipping.
python3 -m unittest discover -s tests
"""
import html
import json
import re
import shutil
import tempfile
import unittest
from pathlib import Path
from tests.test_site_build import (BUILDER, REPO, SITE, ScratchCase,
needs_renderer, run_build)
INSTALL = REPO / "install.py"
BOARD = REPO / "manager" / "core" / "board.py"
CONFIG = REPO / "manager" / "core" / "config.py"
# The hero terminal, line by line, minus the install command: what the
# page shows, a fragment of it that the source can be searched for, and
# the file that prints it. Two fragments are shorter than the line they
# check because the f-string that produces them is wrapped across two
# source lines — the page still has to carry the whole line.
#
# The page shows fewer lines than a real first run does; its title bar
# says "abridged". It shows none a first run does not.
TRANSCRIPT = [
("a few questions and bench writes one.",
"a few questions and bench writes", INSTALL),
("Enter takes the default in [brackets]; Ctrl-D skips the rest.",
"Enter takes the default in [brackets]; Ctrl-D skips the rest.", INSTALL),
("solo or team?", "solo or team?", INSTALL),
("which agent adapter?", "which agent adapter?", INSTALL),
("what command runs this project's tests?",
"what command runs this project's tests?", INSTALL),
("python3 -m unittest", "python3 -m unittest", INSTALL),
("every other setting is in there, commented; edit it any time.",
"every other setting is in there,", INSTALL),
("Task board for", "Task board for", BOARD),
("http://127.0.0.1:", "http://127.0.0.1:", BOARD),
("Ctrl-C to stop", "Ctrl-C to stop", BOARD),
("26071", "26071", CONFIG),
]
# Words for things bench does not have. Turn 1 of the design documented
# several of them; the page must never grow them back.
FICTION = ("bench.toml", "brew install", "npm install", "sqlite", "webhook",
"sign up", "sign in", "log in", "your account", "api key",
"lanes", "7331", "cloud", "pricing", "free trial")
def text_of(path: Path) -> str:
return path.read_text(encoding="utf-8")
class LandingCase(unittest.TestCase):
"""The real site, built once into a scratch directory."""
@classmethod
def setUpClass(cls):
try:
import markdown_it # noqa: F401
except ImportError: # pragma: no cover - environment
raise unittest.SkipTest("markdown-it-py is not installed")
cls.out = Path(tempfile.mkdtemp(prefix="bench-landing-")).resolve()
result = run_build(REPO, cls.out)
if result.returncode != 0: # not assert: must survive python -O
raise RuntimeError(f"site/build.py failed:\n{result.stdout}"
f"{result.stderr}")
cls.manifest = json.loads(text_of(SITE / "pages.json"))
cls.home = text_of(cls.out / "index.html")
@classmethod
def tearDownClass(cls):
if hasattr(cls, "out"):
shutil.rmtree(cls.out, ignore_errors=True)
def terminal(self) -> str:
"""The hero terminal's text, as a reader sees it."""
block = re.search(r'<pre class="terminal-body">(.*?)</pre>',
self.home, re.DOTALL)
self.assertTrue(block, "the landing page has no terminal")
return html.unescape(re.sub(r"<[^>]+>", "", block.group(1)))
class TheFactsAreRead(LandingCase):
"""Neither of these is allowed to be a copy. A landing page with a
subtly wrong curl is worse than no landing page."""
def test_the_install_command_is_the_readmes_own_line_for_line(self):
shown = self.terminal()
for line in BUILDER.install_command(REPO):
self.assertIn(line, shown,
"the terminal does not show README.md's install "
"command as README.md writes it")
def test_the_version_shown_is_the_one_in_the_version_file(self):
version = text_of(REPO / "manager" / "core" / "VERSION").strip()
self.assertIn(version, self.home)
def test_the_manifest_keeps_no_second_copy_of_the_version(self):
self.assertNotIn("version", self.manifest["site"],
"pages.json names a version; the build reads "
"manager/core/VERSION")
class TheTerminalIsATranscript(LandingCase):
"""The strongest element on the page and the easiest one to fake."""
def test_every_line_shown_is_a_line_something_really_prints(self):
shown = self.terminal()
for line, fragment, source in TRANSCRIPT:
with self.subTest(line=line):
self.assertIn(line, shown,
"the terminal no longer shows this")
self.assertIn(fragment, text_of(source),
f"{source.relative_to(REPO)} no longer prints "
f"this — the landing page is now fiction")
def test_the_abridgement_is_declared_where_the_transcript_is(self):
"""It shows fewer lines than a first run prints. Saying so in the
terminal's own title bar is the difference between an excerpt and
a claim that this is the whole output."""
self.assertIn("abridged", self.home)
class TheDoorsOpen(LandingCase):
"""Six doors, each a page this site builds."""
def doors(self) -> list:
return re.findall(r'<a class="door[^"]*" href="([^"]+)"', self.home)
def test_there_are_six_and_they_are_distinct(self):
self.assertEqual(6, len(self.doors()))
self.assertEqual(6, len(set(self.doors())))
def test_each_one_is_a_route_the_build_writes(self):
routes = {page["path"] for page in self.manifest["pages"]}
for door in self.doors():
with self.subTest(door=door):
self.assertIn(door, routes)
self.assertTrue(
BUILDER.target_for(self.out, door).is_file(),
f"{door} is in the manifest but produced no page")
def test_both_hero_buttons_go_somewhere_real(self):
"""One to the install guide, one to the repository."""
hero = re.search(r'<div class="hero-actions">(.*?)</div>',
self.home, re.DOTALL).group(1)
links = re.findall(r'href="([^"]+)"', hero)
self.assertEqual(2, len(links))
routes = {page["path"] for page in self.manifest["pages"]}
self.assertIn("/guides/install/", links)
self.assertIn("/guides/install/", routes)
self.assertIn(self.manifest["site"]["repo_url"], links)
class WhatThePageMayNotSay(LandingCase):
"""Nothing here claims a feature bench lacks, counts anything it
cannot count, or needs a script to be read."""
def test_the_fake_telemetry_is_gone(self):
"""Turn 1's "Most opened this week" was analytics this site does
not have and will not get."""
self.assertNotIn("Most opened", self.home)
def test_no_page_claims_something_bench_does_not_have(self):
lowered = self.home.lower()
for word in FICTION:
with self.subTest(word=word):
self.assertNotIn(word, lowered)
def test_the_page_needs_no_javascript(self):
for page in self.manifest["pages"]:
html_text = text_of(BUILDER.target_for(self.out, page["path"]))
with self.subTest(page=page["path"]):
self.assertNotIn("<script", html_text.lower())
self.assertNotIn("<noscript", html_text.lower())
self.assertIsNone(
re.search(r"\son[a-z]+=", html_text, re.IGNORECASE),
"an inline event handler is a page that needs a script")
def test_it_carries_the_card_a_pasted_url_needs(self):
canonical = (self.manifest["site"]["base_url"].rstrip("/") + "/")
entry = next(p for p in self.manifest["pages"] if p["path"] == "/")
self.assertIn(f'<title>{entry["title"]}</title>', self.home)
for tag, value in (("og:type", "website"),
("og:title", entry["title"]),
("og:description", entry["description"]),
("og:url", canonical)):
with self.subTest(tag=tag):
self.assertIn(f'<meta property="{tag}" content="{value}">',
self.home)
self.assertIn(f'<link rel="canonical" href="{canonical}">', self.home)
self.assertIn(f'<meta name="description" content='
f'"{entry["description"]}">', self.home)
@needs_renderer
class ChangingASourceChangesThePage(ScratchCase):
"""The acceptance criterion the other way round: edit the file the
fact is read from, rebuild, and the page has moved."""
def home(self) -> str:
return (self.repo.out / "index.html").read_text("utf-8")
def test_editing_the_version_file_moves_the_version_on_the_page(self):
self.repo.edit("manager/core/VERSION", "0.2-alpha", "9.9-rc1")
self.assertEqual(0, self.repo.build().returncode)
self.assertIn("9.9-rc1", self.home())
self.assertNotIn("0.2-alpha", self.home())
def test_editing_the_readme_moves_the_command_on_the_page(self):
self.repo.edit("README.md", "mkdir .task-manager && curl -L \\",
"mkdir .bench && curl -sSL \\")
self.assertEqual(0, self.repo.build().returncode)
self.assertIn("mkdir .bench &amp;&amp; curl -sSL", self.home())
self.assertNotIn("mkdir .task-manager &amp;&amp; curl -L",
self.home())
def test_a_missing_version_file_stops_the_build(self):
(self.repo.root / "manager" / "core" / "VERSION").unlink()
result = self.repo.build()
self.assertNotEqual(0, result.returncode)
self.assertIn("manager/core/VERSION", result.stderr)
def test_a_renamed_install_section_stops_the_build(self):
self.repo.edit("README.md", "## Install into a repo",
"## Getting started")
result = self.repo.build()
self.assertNotEqual(0, result.returncode)
self.assertIn("## Install into a repo", result.stderr)
def test_an_install_section_with_no_command_stops_the_build(self):
text = (self.repo.root / "README.md").read_text("utf-8")
start = text.index("## Install into a repo")
end = text.index("## Update")
(self.repo.root / "README.md").write_text(
text[:start] + "## Install into a repo\n\nSoon.\n\n" + text[end:],
encoding="utf-8")
result = self.repo.build()
self.assertNotEqual(0, result.returncode)
self.assertIn("no fenced command block", result.stderr)
def test_a_version_in_the_manifest_is_refused(self):
manifest = self.repo.manifest()
manifest["site"]["version"] = "1.0"
self.repo.write_manifest(manifest)
result = self.repo.build()
self.assertNotEqual(0, result.returncode)
self.assertIn("VERSION", result.stderr)
@needs_renderer
class ADeadInternalLinkStopsTheBuild(ScratchCase):
"""A door that 404s is the failure this page cannot be allowed to
ship, so it is a build failure rather than a review finding."""
def test_a_link_to_a_route_the_manifest_lost_is_named(self):
manifest = self.repo.manifest()
manifest["pages"] = [page for page in manifest["pages"]
if page["path"] != "/guides/install/"]
self.repo.write_manifest(manifest)
result = self.repo.build()
self.assertNotEqual(0, result.returncode)
self.assertIn("/guides/install/", result.stderr)
self.assertIn("does not write", result.stderr)
def test_it_fails_before_anything_is_written(self):
"""As drift does: the last good build stays up."""
manifest = self.repo.manifest()
manifest["pages"] = [page for page in manifest["pages"]
if page["path"] != "/concepts/team-mode/"]
self.repo.write_manifest(manifest)
self.repo.build()
self.assertFalse(self.repo.out.exists(),
"a build with a dead link wrote pages anyway")
def test_a_relative_link_is_refused(self):
home = self.repo.plain_home()
(self.repo.root / "site" / "templates" / "plain.html").write_text(
'<a href="guides/install/">x</a>$title$body', encoding="utf-8")
manifest = self.repo.manifest()
manifest["pages"] = [home]
self.repo.write_manifest(manifest)
result = self.repo.build()
self.assertNotEqual(0, result.returncode)
self.assertIn("guides/install/", result.stderr)
def test_a_stamped_asset_url_is_not_mistaken_for_a_dead_one(self):
"""The stylesheet is linked with a ?v=<hash>; the check has to
read past the query string rather than call it a dead link."""
result = self.repo.build()
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("/static/site.css?v=",
(self.repo.out / "index.html").read_text("utf-8"))
if __name__ == "__main__":
unittest.main()