site: a minisite generated from the repo's own markdown

bench has no public page. site/ is the machinery for one: a manifest,
a generator, two layouts from the turn-2 docs design, and the Daylight
half of the board's own token set.

Nothing here is transcribed. Every page body is a heading slice of a
file that already documents bench, and site/pages.json is the only
place that mapping is written down — the IA is editorial judgement, so
it lives in one readable file rather than in a directory convention.

The point is the failure: a from/to heading the source no longer
contains stops the build naming the route, the file and the heading,
and every page renders before the output directory is touched, so a
drifting rebuild leaves the last good build standing instead of
replacing it with an empty one. Repo-relative links come out as site
routes or GitHub links; a dead one stops the build too.

Rendering is markdown-it-py, pinned in site/requirements.txt. The
stdlib-only law binds manager/core/ — the tool people install — and
site/ is neither shipped nor installed, so a hand-rolled parser would
have been risk spent in the wrong place on a document that leans on
fenced code, nested lists and inline code throughout.

Templates are string.Template: $name substitutes and a literal dollar
doubles, because str.format's braces collide with every line of CSS.

Fonts are self-hosted and same-origin: no page bench serves may need a
request to a third party to be read. fetch-fonts.py downloads the seven
woff2 files once; until they are there the build says which are missing
and the pages fall back to the system stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-31 11:30:34 +02:00
co-authored by Claude Opus 5
parent eafd1ad546
commit 62caafe157
10 changed files with 1292 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
# site/ — bench.12vectors.com
A static minisite whose content is *generated* from the files that
already define bench. Nothing here is transcribed prose: every page body
is a heading slice of `AGENTS.md`, `README.md`,
`manager/core/.env.example` or `manager/core/adapters/README.md`, and
`pages.json` is the only place that mapping is written down.
That is the point. Rename a section in `AGENTS.md` and this build stops,
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.
```bash
python3 -m pip install -r site/requirements.txt # once
python3 site/fetch-fonts.py # once, needs network
python3 site/build.py # → site/dist/
```
`site/dist/` is gitignored — it is output, rebuilt on every deploy.
Deployment is task 32's; this directory only has to produce the files.
`site/` never reaches a host repo: releases ship exactly what
`../manager/core/release-manifest` lists, and it does not list this
directory. `tests/test_release_artifact.py` asserts that out loud rather
than leaving it to inference.
## What is where
| Path | What it is |
| --- | --- |
| `pages.json` | The manifest: one entry per route, and the site's whole IA |
| `build.py` | The generator — slicing, drift detection, rendering, links |
| `templates/` | One `string.Template` per layout (`$name`, `$$` for a literal dollar) |
| `static/` | Copied to `dist/static/` verbatim: the stylesheet, the icon, the fonts |
| `requirements.txt` | `markdown-it-py`, pinned. The only dependency |
| `fetch-fonts.py` | Downloads the self-hosted woff2 files, once |
## A manifest entry
```json
{
"path": "/concepts/claiming-a-card/",
"title": "Claiming a card",
"layout": "article",
"section": "Concepts",
"description": "…",
"source": "AGENTS.md",
"from": "## Claiming a card",
"to": "## Syncing boards"
}
```
- **`path`** starts and ends with `/`; `/x/y/` is written to
`dist/x/y/index.html`.
- **`layout`** names a file in `templates/`.
- **`section`** groups the page in the nav and the sidebar. The IA is
read out of this file in this file's order — nothing is derived from
the directory layout.
- **`source`** is repo-relative, or `null` for a landing page whose body
is authored in its template rather than sliced.
- **`from`** is the heading the slice starts at. It is matched on the
heading's text; writing the `#`s (`## Stages`) pins the level too.
Headings inside fenced code blocks never match.
- **`to`** is optional. Without it the slice runs to the next heading of
the same level or shallower.
The `from` heading itself is dropped — the layout renders the page title
— and what remains is promoted by `level - 1`, so a section's `###`
sub-headings land as the page's `<h2>`s.
## What fails the build
Each of these exits non-zero with a message naming the route:
- a `source` file that no longer exists;
- a `from` or `to` heading the source no longer contains;
- 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;
- a template placeholder the builder does not supply.
Repo-relative links that *do* resolve are rewritten: to a site route if
`link_routes` maps the file to one, otherwise to the file on GitHub.
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""Generate the bench minisite from the repo's own markdown.
python3 site/build.py # writes site/dist/
python3 site/build.py --out /tmp/x # somewhere else
Nothing here is transcribed. Every page body is a heading slice of a file
that already documents bench — AGENTS.md, README.md, the settings example,
the adapter contract — and site/pages.json is the only place that mapping
is written down. That is the whole point: renaming a section in AGENTS.md
must break this build, loudly, naming the route and the heading it can no
longer find, rather than quietly emitting a page with an empty body.
The stdlib-only law binds `manager/core/` — the tool people install. This
directory is neither shipped nor installed (see manager/core/release-manifest,
"Anything not listed here does not ship"), so it may depend on a real
markdown parser: markdown-it-py, pinned in site/requirements.txt.
## How a slice becomes a page
Given `"from": "## Claiming a card"` the builder takes the lines after
that heading up to the next heading of the same level or shallower (or to
an explicit `"to"` heading), then promotes what is left by `level - 1` so
the section's own `###` sub-headings land as the page's `<h2>`s. The
`from` heading itself is dropped: the layout renders the page title from
the manifest, and a body that repeated it would say it twice.
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.
"""
import argparse
import json
import os
import re
import shutil
import sys
from html import escape
from pathlib import Path
from string import Template
SITE = Path(__file__).resolve().parent
REPO = SITE.parent
# Written into every output directory the builder owns, so a later run
# knows the tree is its own before removing it. --out pointed at
# something else refuses rather than deleting a stranger's files.
MARKER = ".bench-site"
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*\)""")
EXTERNAL = ("http://", "https://", "//", "mailto:", "tel:", "data:")
class BuildError(Exception):
"""A failure a person must read and fix: a missing source, a renamed
heading, a dead link, a manifest that does not make sense."""
# ── reading markdown ──────────────────────────────────────────────────
def headings(text: str):
"""(line index, level, text) for every ATX heading *outside* a code
fence. The fence tracking is not a nicety: AGENTS.md fences a task
file template whose first line is `# Task title`, and matching that
would slice the document in half."""
fence = None
for index, line in enumerate(text.splitlines()):
opener = FENCE.match(line)
if opener:
marker = opener.group(1)
if fence is None:
fence = marker
elif marker[0] == fence[0] and len(marker) >= len(fence) \
and not line.strip().strip(marker[0]):
fence = None
continue
if fence is not None:
continue
found = ATX.match(line)
if found:
yield index, len(found.group(1)), found.group(2).strip()
def wanted(value: str):
"""A manifest heading as (level or None, text). `## Stages` pins the
level too; a bare `Stages` matches the heading wherever it sits."""
text = value.lstrip("#")
level = len(value) - len(text)
return (level or None), text.strip()
def find_heading(marks: list, value: str, after: int = -1):
level, text = wanted(value)
for index, found_level, found_text in marks:
if index <= after:
continue
if found_text == text and (level is None or level == found_level):
return index, found_level
return None
def promote(text: str, by: int) -> str:
"""Shift every heading in a slice `by` levels shallower, so a section
lifted out of a larger document keeps its internal hierarchy while
starting at <h2> under the page's own <h1>."""
if by <= 0:
return text
lines = text.splitlines()
for index, level, _ in list(headings(text)):
found = ATX.match(lines[index])
lines[index] = "#" * max(1, level - by) + " " + found.group(2).strip()
return "\n".join(lines)
def slice_section(text: str, page: dict, source: str) -> str:
"""The body of one page, or a BuildError naming what drifted."""
route = page["path"]
marks = list(headings(text))
start = find_heading(marks, page["from"])
if start is None:
raise BuildError(
f'{route}: {source} has no heading "{page["from"]}" — the '
f"section was renamed, moved or deleted. Fix the heading or "
f"update the entry in site/pages.json.")
start_line, level = start
if page.get("to"):
end = find_heading(marks, page["to"], after=start_line)
if end is None:
raise BuildError(
f'{route}: {source} has no heading "{page["to"]}" after '
f'"{page["from"]}" — the slice has no end. Fix the heading '
f"or update the entry in site/pages.json.")
end_line = end[0]
else:
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())
body = "\n".join(text.splitlines()[start_line + 1:end_line]).strip("\n")
if not body.strip():
raise BuildError(
f'{route}: the slice of {source} from "{page["from"]}" is '
f"empty. A page with no body is a drift, not a page.")
return promote(body, level - 1)
# ── links ─────────────────────────────────────────────────────────────
def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
repo: Path) -> str:
"""A repo-relative link out of a markdown file is a dead path on the
web. Send it to the site route that covers it, or to the file on
GitHub — and refuse to emit anything else."""
if not href or href.startswith("#") or href.startswith(EXTERNAL):
return href
target, hash_mark, fragment = href.partition("#")
if not target:
return href
rel = os.path.normpath(os.path.join(os.path.dirname(source), target))
if rel.startswith(".."):
raise BuildError(
f'{page["path"]}: {source} links to "{href}", which is outside '
f"the repository. Only links within the repo can be rewritten.")
if not (repo / rel).exists():
raise BuildError(
f'{page["path"]}: {source} links to "{href}", which does not '
f"exist ({rel}). A dead link in a source file is a dead link "
f"on the site.")
route = manifest.get("link_routes", {}).get(rel)
if route:
return route + hash_mark + fragment
blob = manifest["site"]["blob_base"].rstrip("/") + "/"
return blob + rel + hash_mark + fragment
# ── rendering ─────────────────────────────────────────────────────────
def slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug or "section"
def render_markdown(body: str, *, page: dict, source: str, manifest: dict,
repo: Path):
"""(html, [(slug, text)] for the h2s) — heading ids and rewritten
links are done on the token stream, not with regexes over HTML."""
try:
from markdown_it import MarkdownIt
except ImportError as missing: # pragma: no cover - environment
raise BuildError(
"markdown-it-py is not installed. It is the site's only "
"dependency: python3 -m pip install -r site/requirements.txt"
) from missing
renderer = MarkdownIt("commonmark").enable(["table", "strikethrough"])
tokens = renderer.parse(body)
contents, seen = [], {}
for index, token in enumerate(tokens):
if token.type == "heading_open":
text = tokens[index + 1].content
slug = slugify(text)
seen[slug] = seen.get(slug, 0) + 1
if seen[slug] > 1:
slug = f"{slug}-{seen[slug]}"
token.attrSet("id", slug)
if token.tag == "h2":
contents.append((slug, text))
elif token.type == "inline":
for child in token.children or []:
if child.type == "link_open":
child.attrSet("href", rewrite_link(
child.attrGet("href"), page=page, source=source,
manifest=manifest, repo=repo))
elif child.type == "image":
child.attrSet("src", rewrite_link(
child.attrGet("src"), page=page, source=source,
manifest=manifest, repo=repo))
return renderer.renderer.render(tokens, renderer.options, {}), contents
def read_source(page: dict, repo: Path) -> str:
source = page["source"]
path = repo / source
if not path.is_file():
raise BuildError(
f'{page["path"]}: source file {source} does not exist. The '
f"file was renamed or moved; update site/pages.json.")
return path.read_text(encoding="utf-8")
# ── the shell around a body ───────────────────────────────────────────
def sections(manifest: dict) -> list:
"""The IA, read out of the manifest in manifest order: the sections
that have pages, each with its pages. Nothing is derived from the
directory layout — pages.json is where the site's shape is written."""
groups: list = []
for page in manifest["pages"]:
name = page.get("section")
if not name:
continue
for group in groups:
if group["name"] == name:
group["pages"].append(page)
break
else:
groups.append({"name": name, "pages": [page]})
return groups
def render_nav(manifest: dict, current: dict) -> str:
out = []
for group in sections(manifest):
first = group["pages"][0]
active = " nav-here" if current.get("section") == group["name"] else ""
out.append(f'<a class="nav-link{active}" href="{first["path"]}">'
f'{escape(group["name"])}</a>')
return "\n".join(out)
def render_sidebar(manifest: dict, current: dict) -> str:
out = []
for group in sections(manifest):
out.append('<div class="side-group">')
out.append(f'<span class="side-label">{escape(group["name"])}</span>')
for page in group["pages"]:
here = " side-here" if page["path"] == current["path"] else ""
out.append(f'<a class="side-link{here}" href="{page["path"]}">'
f'{escape(page["title"])}</a>')
out.append("</div>")
return "\n".join(out)
def render_contents(contents: list) -> str:
if not contents:
return ""
out = ['<span class="toc-label">On this page</span>']
for slug, text in contents:
out.append(f'<a class="toc-link" href="#{slug}">{escape(text)}</a>')
return "\n".join(out)
def render_breadcrumb(page: dict) -> str:
if not page.get("section"):
return ""
return (f'<span>{escape(page["section"])}</span><span></span>'
f'<span class="crumb-here">{escape(page["title"])}</span>')
def load_template(name: str, site: Path) -> Template:
path = site / "templates" / f"{name}.html"
if not path.is_file():
available = sorted(p.stem for p in (site / "templates").glob("*.html"))
raise BuildError(
f'no template for layout "{name}". Templates present: '
f'{", ".join(available) or "none"}.')
return Template(path.read_text(encoding="utf-8"))
def render_page(page: dict, manifest: dict, *, site: Path, repo: Path) -> str:
source = page.get("source")
if source:
body, contents = render_markdown(
slice_section(read_source(page, repo), page, source),
page=page, source=source, manifest=manifest, repo=repo)
else:
# An authored landing page says so with "source": null. Its words
# live in the template, so there is no slice and nothing to drift.
body, contents = "", []
config = manifest["site"]
blob = config["blob_base"].rstrip("/") + "/"
fields = {
"title": escape(page["title"]),
"description": escape(page.get("description")
or config.get("description", "")),
"site_title": escape(config["title"]),
"site_tagline": escape(config.get("tagline", "")),
"version": escape(config.get("version", "")),
"body": body,
"toc": render_contents(contents),
"nav": render_nav(manifest, page),
"sidebar": render_sidebar(manifest, page),
"breadcrumb": render_breadcrumb(page),
"section": escape(page.get("section") or ""),
"repo_url": config["repo_url"],
"issues_url": config.get("issues_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"],
}
try:
return load_template(page["layout"], site).substitute(fields)
except KeyError as unknown:
raise BuildError(
f'{page["layout"]}.html uses an unknown placeholder '
f"${unknown.args[0]}. Known: "
f'{", ".join("$" + k for k in sorted(fields))}.') from unknown
except ValueError as bad:
raise BuildError(
f"{page['layout']}.html: {bad}. A literal dollar sign in a "
f"template must be written $$.") from bad
# ── the manifest ──────────────────────────────────────────────────────
def load_manifest(site: Path) -> dict:
path = site / "pages.json"
if not path.is_file():
raise BuildError(f"no manifest at {path}")
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as broken:
raise BuildError(f"pages.json is not valid JSON: {broken}") from broken
for key in ("site", "pages"):
if key not in manifest:
raise BuildError(f'pages.json has no "{key}" key')
for key in ("title", "repo_url", "blob_base"):
if key not in manifest["site"]:
raise BuildError(f'pages.json: site has no "{key}" key')
seen = set()
for page in manifest["pages"]:
for key in ("path", "title", "layout"):
if not page.get(key):
raise BuildError(f'pages.json: an entry has no "{key}": '
f"{json.dumps(page)}")
route = page["path"]
if not route.startswith("/") or not route.endswith("/"):
raise BuildError(f'pages.json: route "{route}" must start and '
f'end with "/"')
if route in seen:
raise BuildError(f'pages.json: route "{route}" appears twice')
seen.add(route)
if "source" not in page:
raise BuildError(
f'{route}: no "source". A page generated from a file names '
f'it; an authored page says "source": null.')
if page["source"] and not page.get("from"):
raise BuildError(
f'{route}: "source" is {page["source"]} but there is no '
f'"from" heading to slice from.')
if not page["source"] and (page.get("from") or page.get("to")):
raise BuildError(
f'{route}: "source" is null, so "from"/"to" have nothing '
f"to slice. Remove them or name a source.")
return manifest
# ── output ────────────────────────────────────────────────────────────
def clear(out: Path) -> None:
"""Empty the output directory — but only one this builder made. A
--out pointed at something else stops the build instead."""
if not out.exists():
return
if not out.is_dir():
raise BuildError(f"{out} is not a directory")
if any(out.iterdir()) and not (out / MARKER).exists():
raise BuildError(
f"{out} is not empty and was not written by this builder "
f"(no {MARKER}). Refusing to delete it.")
shutil.rmtree(out)
def copy_static(site: Path, out: Path) -> None:
static = site / "static"
if static.is_dir():
shutil.copytree(static, out / "static",
ignore=shutil.ignore_patterns(".DS_Store"))
def missing_assets(out: Path) -> list:
"""Every same-origin url() a stylesheet asks for that is not in the
output. The fonts are self-hosted on purpose — the shipped site makes
no third-party request — so an absent woff2 is worth saying out loud
even though the page still renders on the fallback stack."""
absent = []
for sheet in sorted((out / "static").rglob("*.css")):
for url in CSS_URL.findall(sheet.read_text(encoding="utf-8")):
if url.startswith(EXTERNAL):
continue
target = (out / url.lstrip("/")) if url.startswith("/") \
else (sheet.parent / url)
if not target.exists():
absent.append(url)
return sorted(set(absent))
def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
log=print) -> list:
site = site or (repo / "site")
out = out or (site / "dist")
manifest = load_manifest(site)
pages = [(page, render_page(page, manifest, site=site, repo=repo))
for page in manifest["pages"]]
clear(out)
out.mkdir(parents=True)
(out / MARKER).write_text(
"written by site/build.py; safe to delete\n", encoding="utf-8")
copy_static(site, out)
written = []
for page, html in pages:
target = out / page["path"].strip("/") / "index.html"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(html, encoding="utf-8")
written.append(target)
log(f" {page['path']:<34} {target.relative_to(out)}")
return written
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="Build the bench minisite into site/dist/.")
parser.add_argument("--repo", type=Path, default=REPO,
help="repository root (default: the one above site/)")
parser.add_argument("--site", type=Path, default=None,
help="site directory (default: <repo>/site)")
parser.add_argument("--out", type=Path, default=None,
help="output directory (default: <site>/dist)")
parser.add_argument("-q", "--quiet", action="store_true")
args = parser.parse_args(argv)
repo = args.repo.resolve()
site = (args.site or repo / "site").resolve()
out = (args.out or site / "dist").resolve()
log = (lambda *a: None) if args.quiet else print
try:
written = build(repo=repo, site=site, out=out, log=log)
except BuildError as failure:
print(f"error: {failure}", file=sys.stderr)
return 1
for url in missing_assets(out):
print(f"warning: {url} is referenced by the stylesheet but is not "
f"in the build — see site/static/fonts/README.md",
file=sys.stderr)
log(f"{len(written)} pages → {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Download the site's woff2 files into site/static/fonts/.
python3 site/fetch-fonts.py # fetch what is missing
python3 site/fetch-fonts.py --force # fetch everything again
The site self-hosts its type: no page bench serves may make a request to
a third party, and a linked font CDN is exactly that request. This script
is the one moment the fonts come from Google — at build time, on someone's
machine — after which the files are the site's own.
It asks the CSS API for each face on its own, so the reply names exactly
one woff2 per request and there is nothing to guess about which URL is
which weight. The `latin` subset is what that API already serves.
Stdlib only, network required. If you are offline, copy the files in by
hand; site/static/fonts/README.md lists the seven names.
"""
import argparse
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
FONTS = Path(__file__).resolve().parent / "static" / "fonts"
API = "https://fonts.googleapis.com/css2"
# A modern browser UA is what makes the API answer in woff2 rather than
# in one of the older formats it keeps for old clients.
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
WOFF2 = re.compile(r"url\((https://[^)]+\.woff2)\)")
# (output name, family, css spec) — the css spec is the `family=` value.
FACES = [
("IBMPlexSans-Regular.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,400"),
("IBMPlexSans-Italic.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@1,400"),
("IBMPlexSans-Medium.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,500"),
("IBMPlexSans-SemiBold.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,600"),
("IBMPlexMono-Regular.woff2", "IBM Plex Mono", "IBM+Plex+Mono:ital,wght@0,400"),
("IBMPlexMono-Medium.woff2", "IBM Plex Mono", "IBM+Plex+Mono:ital,wght@0,500"),
("ZillaSlab-SemiBold.woff2", "Zilla Slab", "Zilla+Slab:wght@600"),
]
def get(url: str, *, binary: bool = False):
request = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(request, timeout=30) as reply:
raw = reply.read()
return raw if binary else raw.decode("utf-8")
def fetch(face: tuple, force: bool) -> str:
name, family, spec = face
target = FONTS / name
if target.exists() and not force:
return f" kept {name}"
css = get(f"{API}?family={spec}&display=swap&subset=latin")
urls = WOFF2.findall(css)
if not urls:
raise RuntimeError(
f"{family}: the CSS API answered with no woff2 url. It may have "
f"changed its reply for this User-Agent; fetch by hand.")
# The API lists subsets in order; latin is the one the site needs and
# is the last block it emits for these families.
target.write_bytes(get(urls[-1], binary=True))
return f" wrote {name} ({len(urls)} subsets offered)"
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--force", action="store_true",
help="re-download faces that are already here")
args = parser.parse_args(argv)
FONTS.mkdir(parents=True, exist_ok=True)
print(f"fonts → {FONTS}")
for face in FACES:
try:
print(fetch(face, args.force))
except (urllib.error.URLError, RuntimeError) as failure:
print(f"error: {face[0]}: {failure}", file=sys.stderr)
return 1
print("done — the site now serves its own type, same-origin.")
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
{
"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",
"blob_base": "https://github.com/12vectors/bench/blob/main/"
},
"link_routes": {},
"pages": [
{
"path": "/",
"title": "bench",
"layout": "home",
"section": null,
"description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
"source": "AGENTS.md",
"from": "## Stages",
"to": "## Moving a task"
},
{
"path": "/concepts/claiming-a-card/",
"title": "Claiming a card",
"layout": "article",
"section": "Concepts",
"description": "Claiming is moving: taking a card towards work is the commitment, and that is where ownership is recorded.",
"source": "AGENTS.md",
"from": "## Claiming a card",
"to": "## Syncing boards"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
# The minisite's only dependency, pinned.
#
# The stdlib-only law binds manager/core/ — the tool people install. This
# directory is neither shipped nor installed (manager/core/release-manifest:
# "Anything not listed here does not ship"), and AGENTS.md leans on fenced
# code, nested lists, inline code and tables, which is a bad place to spend
# risk on a hand-rolled parser.
#
# python3 -m pip install -r site/requirements.txt
#
markdown-it-py==4.0.0
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill-rule="evenodd"><rect width="16" height="16" rx="4" fill="#0d6e8c"/><path fill="#ffffff" transform="translate(4.81 3.57) scale(.011)" d="M15 0H178V210H330C450 210 565 305 565 420V530C565 645 450 740 330 740H15V648H60V92H15ZM178 314V636H305C385 636 447 585 447 530V420C447 365 385 314 305 314Z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

+42
View File
@@ -0,0 +1,42 @@
# The site's fonts live here
Reading bench's documentation must not require a request to anyone else,
so the site links no font CDN. `site/static/site.css` declares its faces
against files in this directory, and everything the built pages fetch is
same-origin.
Seven files, exactly these names — they are what the `@font-face` rules
in `site.css` ask for:
| File | Family | Weight | Style |
| --- | --- | --- | --- |
| `IBMPlexSans-Regular.woff2` | IBM Plex Sans | 400 | normal |
| `IBMPlexSans-Italic.woff2` | IBM Plex Sans | 400 | italic |
| `IBMPlexSans-Medium.woff2` | IBM Plex Sans | 500 | normal |
| `IBMPlexSans-SemiBold.woff2` | IBM Plex Sans | 600 | normal |
| `IBMPlexMono-Regular.woff2` | IBM Plex Mono | 400 | normal |
| `IBMPlexMono-Medium.woff2` | IBM Plex Mono | 500 | normal |
| `ZillaSlab-SemiBold.woff2` | Zilla Slab | 600 | normal |
## Fetching them
```bash
python3 site/fetch-fonts.py
```
The script asks Google Fonts for the CSS these faces would need, reads
the `woff2` URLs out of the reply, and writes the files here under the
names above. What it downloads is already the `latin` subset Google
serves — the site's copy of the font, not a link to Google's.
Both families are licensed for this: IBM Plex under the SIL Open Font
License 1.1, Zilla Slab likewise. Keeping the downloaded `*.LICENSE.txt`
beside the woff2 files satisfies the licence's one obligation.
## If they are absent
`site/build.py` prints a `warning:` line naming every file the
stylesheet wants and the build does not have, and carries on. The pages
render on the fallback stack (`system-ui`, `ui-monospace`, `Georgia`)
and still make no third-party request — the typography is wrong, the
privacy promise is not.
+303
View File
@@ -0,0 +1,303 @@
/* ── Bench docs: the Daylight register of the board's own system ───────
The four state tokens are named exactly as manager/core/board.html
names them — --accent (surf), --calm (pine), --alarm (terracotta),
--idle (driftwood) — because the site and the board are one system,
and colour in both only ever means state. Their values are the board's
light theme; the neutrals are the docs design's paper register.
Fonts are self-hosted: reading bench's docs must not require a request
to anyone else. site/static/fonts/README.md says which files these
@font-face rules want and how to fetch them.
------------------------------------------------------------------- */
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Regular.woff2") format("woff2")}
@font-face{font-family:'IBM Plex Sans';font-style:italic;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Italic.woff2") format("woff2")}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexSans-Medium.woff2") format("woff2")}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url("/static/fonts/IBMPlexSans-SemiBold.woff2") format("woff2")}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2")}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexMono-Medium.woff2") format("woff2")}
@font-face{font-family:'Zilla Slab';font-style:normal;font-weight:600;font-display:swap;src:url("/static/fonts/ZillaSlab-SemiBold.woff2") format("woff2")}
:root{
/* paper */
--bg:#c9dde1; --canvas:#eaf3f4; --surface:#ffffff; --sunken:#e0eef1;
--border:#c5d7db; --border-soft:#d9e6e9;
--text:#12323b; --muted:#4d6e77; --dim:#87a1a8;
/* state — the same four the board carries */
--accent:#0d6e8c; --calm:#5f7f33; --alarm:#b1543a; --idle:#93a8ac;
--on-accent:#ffffff;
/* dark ink: code, terminals, chrome */
--ink:#0c1a20; --ink-deep:#0a161b; --ink-line:#1c343a;
--ink-text:#e9f3f3; --ink-muted:#95afb4; --ink-dim:#62828a;
--ink-accent:#56c2d8; --ink-calm:#a6c96f; --ink-alarm:#e08a63;
/* tinted callouts, both state colours */
--calm-wash:#f4f8e6; --calm-edge:#d6e2a8; --calm-ink:#4a6329;
--alarm-wash:#fbf1ec; --alarm-edge:#ecc9b8; --alarm-ink:#7a3c2a;
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
--display:'Zilla Slab',Georgia,'Times New Roman',serif;
/* type scale, straight off the design */
--t-hero:52px; --t-title:40px; --t-h2:23px; --t-h3:17px;
--t-lede:17px; --t-body:15px; --t-ui:13.5px; --t-code:12.5px;
--t-micro:10.5px;
--radius:11px; --shadow:0 20px 50px -34px rgba(18,50,59,.55);
--shadow-ink:0 18px 40px -30px rgba(12,26,32,.9);
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:var(--canvas); color:var(--text);
font:var(--t-body)/1.68 var(--sans);
-webkit-font-smoothing:antialiased;
}
a{color:var(--accent);text-decoration:none}
a:hover{color:var(--text);text-decoration:underline}
::selection{background:var(--accent);color:var(--on-accent)}
.mono{font-family:var(--mono)}
.dim{color:var(--dim)}
.spacer{flex:1}
.rule{height:1px;background:var(--border-soft);margin:12px 0}
@media (prefers-reduced-motion: reduce){
*,*::before,*::after{animation:none !important;transition:none !important}
}
/* ── chrome ── */
.topbar{
display:flex;align-items:center;gap:10px;height:30px;padding:0 18px;
background:var(--text);color:#a9c4c9;
font:11px/1 var(--mono);
}
.topbar-org{color:var(--canvas);font-weight:500}
.topbar-sep{color:var(--muted)}
.topbar-repo{color:var(--ink-accent)}
.masthead{
display:flex;align-items:center;gap:20px;padding:14px 30px;
background:var(--surface);border-bottom:1px solid var(--border);
}
.masthead-flat{background:var(--canvas);border-bottom:0}
.wordmark{display:flex;align-items:baseline;gap:9px}
.wordmark:hover{text-decoration:none}
.wordmark-name{
font:600 21px/1 var(--display);letter-spacing:-.015em;color:var(--text);
}
.wordmark-tag{font:12px/1 var(--mono);color:var(--dim)}
.nav{display:flex;gap:18px;font-size:var(--t-ui)}
.nav-link{color:var(--muted);padding-bottom:2px;border-bottom:2px solid transparent}
.nav-link:hover{color:var(--text);text-decoration:none}
.nav-here{color:var(--text);font-weight:600;border-bottom-color:var(--accent)}
.button{
display:inline-block;padding:7px 13px;font:500 var(--t-ui)/1.4 var(--sans);
color:var(--text);background:transparent;
border:1px solid var(--border);border-radius:8px;
}
.button:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
.button-solid{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
.button-solid:hover{background:var(--text);border-color:var(--text);color:var(--on-accent)}
.button-lg{padding:11px 18px;font-size:14px}
.footer{
display:flex;align-items:flex-end;gap:26px;padding:22px 26px;
background:var(--text);color:#a9c4c9;margin-top:40px;
}
.footer-mark{margin:0;font:9.5px/1.25 var(--mono);color:var(--muted)}
.footer-id{display:flex;flex-direction:column;gap:4px}
.footer-name{font:600 15px/1 var(--display);color:var(--canvas)}
.footer-id .mono,.footer-repo{font-size:11px}
.footer-repo{color:#a9c4c9}
/* ── article layout (1a Harbour) ── */
.shell{
display:grid;grid-template-columns:236px minmax(0,1fr) 208px;
background:var(--surface);
}
.side{
border-right:1px solid var(--border-soft);
padding:22px 16px 40px;display:flex;flex-direction:column;gap:22px;
}
.side-group{display:flex;flex-direction:column;gap:7px}
.side-label{
font:var(--t-micro)/1 var(--mono);letter-spacing:.1em;
text-transform:uppercase;color:var(--dim);padding-left:9px;
}
.side-link{font-size:var(--t-ui);color:var(--muted);padding:4px 9px;border-radius:0 6px 6px 0}
.side-link:hover{color:var(--text);text-decoration:none}
.side-here{
font-weight:600;color:var(--text);background:var(--sunken);
border-left:2px solid var(--accent);
}
.side-note{
display:flex;flex-direction:column;gap:5px;margin-top:6px;
padding:11px 12px;background:var(--canvas);
border:1px solid var(--border-soft);border-radius:10px;
font-size:12.5px;line-height:1.45;color:var(--muted);
}
.side-note .mono{font-size:var(--t-micro)}
.gutter{border-left:1px solid var(--border-soft);padding:30px 18px}
.toc{display:flex;flex-direction:column;gap:9px;position:sticky;top:20px}
.toc-label{
font:var(--t-micro)/1 var(--mono);letter-spacing:.1em;
text-transform:uppercase;color:var(--dim);margin-bottom:2px;
}
.toc-link{
font-size:12.5px;color:var(--muted);
border-left:2px solid var(--sunken);padding-left:9px;
}
.toc-link:hover{color:var(--accent);border-left-color:var(--accent);text-decoration:none}
.gutter-link{font-size:12.5px;line-height:1.5;color:var(--muted)}
.marginalia{
margin-top:36px;font:italic 11px/1.55 var(--mono);color:var(--alarm);
transform:rotate(-1.2deg);transform-origin:left top;
}
.marginalia .mono{font-size:11px}
.crumbs{
display:flex;align-items:center;gap:8px;margin-bottom:16px;
font:11.5px/1 var(--mono);color:var(--dim);
}
.crumb-here{color:var(--muted)}
/* ── generated prose ── */
.page-article .prose{padding:30px 40px 44px;min-width:0}
.prose h1{
font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em;
margin:0 0 12px;
}
.prose h2{
font:600 var(--t-h2)/1.3 var(--display);letter-spacing:-.01em;
margin:34px 0 10px;scroll-margin-top:20px;
}
.prose h3{font:600 var(--t-h3)/1.4 var(--sans);margin:26px 0 8px}
.prose h1 + p,.prose > p:first-child{
font-size:var(--t-lede);line-height:1.6;color:var(--muted);max-width:60ch;
}
.prose p{margin:0 0 16px;color:var(--muted);max-width:64ch;text-wrap:pretty}
.prose strong{color:var(--text);font-weight:600}
.prose ul,.prose ol{margin:0 0 18px;padding-left:22px;max-width:64ch;color:var(--muted)}
.prose li{margin:0 0 7px}
.prose li > ul,.prose li > ol{margin:7px 0 0}
.prose hr{border:0;border-top:1px solid var(--border-soft);margin:30px 0}
.prose blockquote{
margin:0 0 20px;padding:14px 16px;background:var(--canvas);
border:1px solid var(--border-soft);border-left:3px solid var(--accent);
border-radius:0 10px 10px 0;color:var(--text);
}
.prose blockquote p:last-child{margin:0}
.prose code{
font:var(--t-code)/1.5 var(--mono);
background:var(--canvas);border-radius:4px;padding:1px 5px;
color:var(--text);
}
.prose pre{
margin:0 0 24px;padding:16px 18px;overflow-x:auto;
background:var(--ink);color:var(--ink-text);
font:var(--t-code)/1.75 var(--mono);
border-radius:var(--radius);box-shadow:var(--shadow-ink);
}
.prose pre code{background:transparent;padding:0;color:inherit;font-size:inherit}
.prose table{
border-collapse:separate;border-spacing:0;margin:0 0 26px;width:100%;
background:var(--surface);border:1px solid var(--border-soft);
border-radius:10px;overflow:hidden;font-size:var(--t-ui);
}
.prose th{
text-align:left;padding:9px 14px;background:var(--canvas);
border-bottom:1px solid var(--border-soft);
font:var(--t-micro)/1.4 var(--mono);letter-spacing:.08em;
text-transform:uppercase;color:var(--dim);
}
.prose td{padding:11px 14px;border-bottom:1px solid var(--canvas);color:var(--muted);vertical-align:top}
.prose tr:last-child td{border-bottom:0}
/* ── home layout (1b Dockside) ── */
.hero{
display:grid;grid-template-columns:1.05fr .95fr;gap:44px;
padding:44px 44px 40px;align-items:center;
}
.hero-copy{display:flex;flex-direction:column;gap:20px}
.eyebrow{
font:11px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
color:var(--accent);
}
.hero h1{
margin:0;font:600 var(--t-hero)/1.05 var(--display);
letter-spacing:-.025em;text-wrap:balance;
}
.lede{
margin:0;max-width:44ch;font-size:var(--t-lede);line-height:1.62;
color:var(--muted);text-wrap:pretty;
}
.hero-actions{display:flex;gap:10px;align-items:center}
.aside-note{font-size:11.5px;font-style:italic;color:var(--dim)}
.terminal{border-radius:12px;overflow:hidden;box-shadow:0 24px 46px -30px rgba(12,26,32,.85)}
.terminal-bar{
display:flex;align-items:center;gap:8px;padding:9px 14px;
background:var(--ink-deep);
}
.dot{width:8px;height:8px;border-radius:99px}
.dot-alarm{background:var(--ink-alarm)}
.dot-calm{background:var(--ink-calm)}
.terminal-title{font-size:11px;color:var(--ink-dim);margin-left:6px}
.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;
}
.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;
}
.doors{
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
padding:0 44px 34px;
}
.door{
display:flex;flex-direction:column;gap:8px;padding:20px;
background:var(--surface);border:1px solid var(--border-soft);
border-radius:12px;
}
.door-dark{background:var(--text);border-color:var(--text);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)}
.door-text{font-size:var(--t-ui);line-height:1.6;color:var(--muted)}
.door-dark .door-text{color:#a9c4c9}
.door .mono{font-size:var(--t-code)}
/* ── narrow ── */
@media (max-width:1080px){
.shell{grid-template-columns:220px minmax(0,1fr)}
.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}
}
@media (max-width:760px){
.shell{grid-template-columns:minmax(0,1fr)}
.side{display:none}
.page-article .prose{padding:24px 22px 36px}
.doors{grid-template-columns:1fr}
.hero h1{font-size:38px}
.masthead{padding:12px 18px;gap:12px}
}
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<!-- Layout "article" — 1a Harbour from the turn-2 docs design: three
columns, the sidebar carrying the IA, the generated body in the
middle, contents and the two GitHub links in the gutter.
Placeholders are string.Template's; a literal dollar in the 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>$title · $site_title</title>
<meta name="description" content="$description">
<link rel="canonical" href="$canonical">
<link rel="icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/site.css">
</head>
<body class="page-article">
<div class="topbar">
<span class="topbar-org">12vectors</span>
<span class="topbar-sep">/</span>
<span class="topbar-repo">bench</span>
<span class="spacer"></span>
<span>docs for $version</span>
</div>
<header class="masthead">
<a class="wordmark" href="/">
<span class="wordmark-name">$site_title</span>
<span class="wordmark-tag">$site_tagline</span>
</a>
<nav class="nav">
$nav
</nav>
<span class="spacer"></span>
<a class="button button-solid" href="$repo_url">GitHub ↗</a>
</header>
<div class="shell">
<aside class="side">
$sidebar
<div class="side-note">
<span class="mono dim">$version</span>
<span>Generated from the repository's own markdown. What the docs
say and what bench does cannot drift apart.</span>
</div>
</aside>
<article class="prose">
<div class="crumbs">$breadcrumb</div>
<h1>$title</h1>
$body
</article>
<aside class="gutter">
<div class="toc">
$toc
<div class="rule"></div>
<a class="gutter-link" href="$source_url">Edit this page on GitHub ↗</a>
<a class="gutter-link" href="$issues_url">Open an issue ↗</a>
</div>
<p class="marginalia">← this page is a slice of
<span class="mono">$source_path</span>; rename that heading and the
build stops</p>
</aside>
</div>
<footer class="footer">
<pre class="footer-mark"> ___________________
|___________________|
|| ||
|| ||</pre>
<div class="footer-id">
<span class="footer-name">$site_title</span>
<span class="mono">a 12vectors product · built in the open</span>
</div>
<span class="spacer"></span>
<a class="mono footer-repo" href="$repo_url">github.com/12vectors/bench</a>
</footer>
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
<!doctype html>
<!-- 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.
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. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$site_title · $site_tagline</title>
<meta name="description" content="$description">
<link rel="canonical" href="$canonical">
<link rel="icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/site.css">
</head>
<body class="page-home">
<div class="topbar">
<span class="topbar-org">12vectors</span>
<span class="topbar-sep">/</span>
<span class="topbar-repo">bench</span>
<span class="spacer"></span>
<span>$version</span>
</div>
<header class="masthead masthead-flat">
<a class="wordmark" href="/">
<span class="wordmark-name">$site_title</span>
<span class="wordmark-tag">$site_tagline</span>
</a>
<span class="spacer"></span>
<nav class="nav">
$nav
</nav>
<a class="button" href="$repo_url">GitHub ↗</a>
</header>
<section class="hero">
<div class="hero-copy">
<span class="eyebrow">Documentation</span>
<h1>Put the agents<br>on the bench.</h1>
<p class="lede">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.</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>
</div>
<span class="mono aside-note">no account, no service, no database. it is
python 3 on your own machine.</span>
</div>
<div class="terminal">
<div class="terminal-bar">
<span class="dot dot-alarm"></span>
<span class="dot dot-calm"></span>
<span class="mono terminal-title">~/your-repo</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
<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>:
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="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
</div>
</section>
<section class="doors">
<div class="door">
<span class="mono door-index">01</span>
<span class="door-title">Install</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">
<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">
<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">
<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">
<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">
<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>
</section>
<footer class="footer">
<pre class="footer-mark"> ___________________
|___________________|
|| ||
|| ||</pre>
<div class="footer-id">
<span class="footer-name">$site_title</span>
<span class="mono">a 12vectors product · built in the open</span>
</div>
<span class="spacer"></span>
<a class="mono footer-repo" href="$repo_url">github.com/12vectors/bench</a>
</footer>
</body>
</html>