diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..4358569 --- /dev/null +++ b/site/README.md @@ -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 `

`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. diff --git a/site/build.py b/site/build.py new file mode 100644 index 0000000..7780a0a --- /dev/null +++ b/site/build.py @@ -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 `

`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

under the page's own

.""" + 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'' + f'{escape(group["name"])}') + return "\n".join(out) + + +def render_sidebar(manifest: dict, current: dict) -> str: + out = [] + for group in sections(manifest): + out.append('
') + out.append(f'{escape(group["name"])}') + for page in group["pages"]: + here = " side-here" if page["path"] == current["path"] else "" + out.append(f'' + f'{escape(page["title"])}') + out.append("
") + return "\n".join(out) + + +def render_contents(contents: list) -> str: + if not contents: + return "" + out = ['On this page'] + for slug, text in contents: + out.append(f'{escape(text)}') + return "\n".join(out) + + +def render_breadcrumb(page: dict) -> str: + if not page.get("section"): + return "" + return (f'{escape(page["section"])}' + f'{escape(page["title"])}') + + +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: /site)") + parser.add_argument("--out", type=Path, default=None, + help="output directory (default: /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()) diff --git a/site/fetch-fonts.py b/site/fetch-fonts.py new file mode 100644 index 0000000..0b807c9 --- /dev/null +++ b/site/fetch-fonts.py @@ -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()) diff --git a/site/pages.json b/site/pages.json new file mode 100644 index 0000000..97cd9f6 --- /dev/null +++ b/site/pages.json @@ -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" + } + ] +} diff --git a/site/requirements.txt b/site/requirements.txt new file mode 100644 index 0000000..3a26846 --- /dev/null +++ b/site/requirements.txt @@ -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 diff --git a/site/static/favicon.svg b/site/static/favicon.svg new file mode 100644 index 0000000..5e581bf --- /dev/null +++ b/site/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/site/static/fonts/README.md b/site/static/fonts/README.md new file mode 100644 index 0000000..5ce4bf4 --- /dev/null +++ b/site/static/fonts/README.md @@ -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. diff --git a/site/static/site.css b/site/static/site.css new file mode 100644 index 0000000..0b377d9 --- /dev/null +++ b/site/static/site.css @@ -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} +} diff --git a/site/templates/article.html b/site/templates/article.html new file mode 100644 index 0000000..c6e2460 --- /dev/null +++ b/site/templates/article.html @@ -0,0 +1,85 @@ + + + + + + +$title · $site_title + + + + + + + +
+ 12vectors + / + bench + + docs for $version +
+ +
+ + $site_title + $site_tagline + + + + GitHub ↗ +
+ +
+ + + +
+
$breadcrumb
+

$title

+$body +
+ + + +
+ +
+ + + + github.com/12vectors/bench +
+ + + diff --git a/site/templates/home.html b/site/templates/home.html new file mode 100644 index 0000000..bfe0448 --- /dev/null +++ b/site/templates/home.html @@ -0,0 +1,147 @@ + + + + + + +$site_title · $site_tagline + + + + + + + +
+ 12vectors + / + bench + + $version +
+ +
+ + $site_title + $site_tagline + + + + GitHub ↗ +
+ +
+
+ Documentation +

Put the agents
on the bench.

+

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.

+ + no account, no service, no database. it is + python 3 on your own machine. +
+ +
+
+ + + ~/your-repo +
+
$$ mkdir .task-manager && curl -L \
+    https://github.com/12vectors/bench/releases/latest/download/bench.tar.gz \
+    | tar -xz -C .task-manager
+$$ ./.task-manager/start.sh
+
+  No .task-manager/manager/local/.env yet — a few questions and bench writes one.
+    solo or team? [solo]:
+    which agent adapter? [claude]:
+    what command runs this project's tests? [python3 -m unittest]:
+
+  Task board for ~/your-repo/.task-manager/tasks
+    http://127.0.0.1:26071/
+    Ctrl-C to stop
+
+
+ +
+
+

The five stages

+

from $source_path — the + section below is this file's, not a retelling of it

+$body +
+
+ +
+
+ 01 + Install + Untar it into .task-manager/, + run start.sh, answer three questions. + Port 26071, pinned. +
+
+ 02 + The five stages + backlog → to-do → in-progress → review → done. + The directory a file sits in is its status. +
+
+ 03 + Agents on the board + ▸ start work makes a worktree and a branch, runs + the agent headless, and moves the card when it exits. +
+
+ 04 + PRs & review + A card entering review gets a PR opened for it. + Then ◔ review PR, ⚑ copilot, ↻ act on PR — until it settles. +
+
+ 05 + Team mode + BOARD_SYNC=1 makes + origin/main the truth. Moves commit and push themselves; boards pull + on a beat. +
+
+ 06 + The three-layer law + Core knows tasks, worktrees, PRs and events. + Drivers know apps, adapters know vendors, local/ knows your + project. +
+
+ + + + +