diff --git a/site/README.md b/site/README.md index 6409878..cbd78c4 100644 --- a/site/README.md +++ b/site/README.md @@ -11,6 +11,13 @@ naming the route and the heading it can no longer find. A documented behaviour and the behaviour itself cannot drift apart when one is cut from the other. +The landing page is the exception that proves it. Its words are authored +in `templates/home.html` (`"source": null`), so the two facts it must +never get subtly wrong are not written there at all: the install +one-liner is read out of `README.md`'s "Install into a repo" block and +the version out of `manager/core/VERSION`, and the template is offered +them as `$install_block` and `$version`. + ```bash python3 -m pip install -r site/requirements.txt # once python3 site/fetch-fonts.py # once, needs network @@ -168,6 +175,13 @@ Each of these exits non-zero with a message naming the route: - a slice that comes out empty; - a markdown link to a repo path that does not exist, or that escapes the repo — a dead relative link must never reach the site; +- an internal link on any rendered page — a door on the landing page as + much as a link inside a slice — that resolves to nothing this build + writes; +- a missing or empty `manager/core/VERSION`, or a `README.md` whose + "Install into a repo" section has lost its command block; +- a `version` key in `pages.json`, which would be a second copy of a + number that has one home; - a template placeholder the builder does not supply; - a file in `root/` that a route would also write. diff --git a/site/build.py b/site/build.py index c3c8e6e..4977b14 100644 --- a/site/build.py +++ b/site/build.py @@ -29,6 +29,17 @@ Templates are `string.Template`, so placeholders are `$name` and a literal dollar is `$$` — `str.format` was not an option with a stylesheet's worth of braces in play. +## What an authored page is still not allowed to type + +The landing page's words are authored in its template rather than sliced, +which would be a hole in the promise above if it extended to facts. It +does not: the install one-liner and the version reach the template as +`$install_block` and `$version`, read from `README.md` and +`manager/core/VERSION` (see `repo_facts`). And every internal link any +page emits — a door on the landing page as much as a link inside a slice +— must resolve to something this build writes, or the build stops +(`check_links`). + ## What the host needs from the build Two things here exist for the way the site is served (site/wrangler.jsonc, @@ -75,8 +86,19 @@ STAMPED = {"stylesheet": "static/site.css", "icon": "static/favicon.svg"} ATX = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$") FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})") CSS_URL = re.compile(r"""url\(\s*["']?([^"')]+)["']?\s*\)""") +LINK = re.compile(r"""(?:href|src)=["']([^"']+)["']""", re.IGNORECASE) +COMMENT = re.compile(r"\s{2,}#") EXTERNAL = ("http://", "https://", "//", "mailto:", "tel:", "data:") +# The two facts the landing page must never carry a hand-typed copy of, +# and the files that own them. A subtly wrong `curl` is worse than no +# landing page at all, and a version the release does not have is a +# support question; both are read, and a source that moved stops the +# build exactly as a renamed heading does. +VERSION_FILE = "manager/core/VERSION" +INSTALL_SOURCE = "README.md" +INSTALL_HEADING = "## Install into a repo" + class BuildError(Exception): """A failure a person must read and fix: a missing source, a renamed @@ -172,6 +194,91 @@ def slice_section(text: str, page: dict, source: str) -> str: return promote(body, level - 1) +# ── facts read out of the repo ──────────────────────────────────────── + +def read_version(repo: Path) -> str: + """`manager/core/VERSION`, which is the version `release.sh` tags.""" + path = repo / VERSION_FILE + if not path.is_file(): + raise BuildError( + f"the site shows the version from {VERSION_FILE}, which does " + f"not exist. It is the one place the version is written; the " + f"site does not keep a second copy.") + version = path.read_text(encoding="utf-8").strip() + if not version: + raise BuildError(f"{VERSION_FILE} is empty.") + return version + + +def install_command(repo: Path) -> list: + """The lines of README.md's install one-liner, verbatim. + + It is the command people paste, so the landing page reads it out of + the file that documents it rather than transcribing it. Editing the + README moves the page; renaming the section stops the build.""" + path = repo / INSTALL_SOURCE + if not path.is_file(): + raise BuildError( + f"the install command is read out of {INSTALL_SOURCE}, which " + f"does not exist.") + text = path.read_text(encoding="utf-8") + marks = list(headings(text)) + start = find_heading(marks, INSTALL_HEADING) + if start is None: + raise BuildError( + f'{INSTALL_SOURCE} has no heading "{INSTALL_HEADING}" — the ' + f"landing page reads the install command from that section. " + f"Fix the heading, or INSTALL_HEADING in site/build.py.") + start_line, level = start + following = [i for i, found_level, _ in marks + if i > start_line and found_level <= level] + end_line = following[0] if following else len(text.splitlines()) + + block, fence = [], None + for line in text.splitlines()[start_line + 1:end_line]: + opener = FENCE.match(line) + if opener and fence is None: + fence = opener.group(1) + continue + if opener: + break + if fence is not None: + block.append(line) + if not block: + raise BuildError( + f'{INSTALL_SOURCE}: the "{INSTALL_HEADING}" section has no ' + f"fenced command block. The landing page's terminal has " + f"nothing to show.") + return block + + +def render_command(lines: list) -> str: + """A command block as terminal html: every line escaped and otherwise + verbatim, a prompt on the lines that begin a command — a line after + one ending in a backslash is a continuation, not a new command — and + a dim tail for a trailing comment.""" + out, continued = [], False + for line in lines: + prompt = "" if continued else '$ ' + continued = line.rstrip().endswith("\\") + found = COMMENT.search(line) + body, tail = (line[:found.start()], line[found.start():]) if found \ + else (line, "") + html = escape(body) + if tail: + html += f'{escape(tail)}' + out.append(prompt + html) + return "\n".join(out) + + +def repo_facts(repo: Path) -> dict: + """What the templates are offered instead of typing it themselves.""" + return { + "version": read_version(repo), + "install_block": render_command(install_command(repo)), + } + + # ── links ───────────────────────────────────────────────────────────── def rewrite_link(href: str, *, page: dict, source: str, manifest: dict, @@ -201,6 +308,42 @@ def rewrite_link(href: str, *, page: dict, source: str, manifest: dict, return blob + rel + hash_mark + fragment +def internal_targets(manifest: dict, site: Path) -> set: + """Every url the built site answers: one per route (and the file that + route is written to), plus everything copied verbatim out of static/ + and root/.""" + urls = set() + for page in manifest["pages"]: + urls.add(page["path"]) + urls.add("/" + target_for(Path(), page["path"]).as_posix()) + for base, prefix in ((site / "static", "/static/"), (site / ROOT, "/")): + if base.is_dir(): + urls.update(prefix + path.relative_to(base).as_posix() + for path in base.rglob("*") if path.is_file()) + return urls + + +def check_links(html: str, page: dict, targets: set) -> None: + """A link on a rendered page that the build does not write is a 404 + with a nice typeface. Markdown links out of a source file are already + resolved (rewrite_link); this is the other half — what the templates + themselves point at, which is where the landing page's doors live.""" + for url in LINK.findall(html): + if not url or url.startswith("#") or url.startswith(EXTERNAL): + continue + if not url.startswith("/"): + raise BuildError( + f'{page["path"]}: links to "{url}". A page is written to ' + f"its own directory, so a relative link resolves against " + f"that; write internal links from the root.") + if url.split("#")[0].split("?")[0] not in targets: + raise BuildError( + f'{page["path"]}: links to "{url}", which this build does ' + f"not write. Every internal link must resolve to a route " + f"in site/pages.json or a file in site/static/ or " + f"site/{ROOT}/.") + + # ── rendering ───────────────────────────────────────────────────────── def slugify(text: str) -> str: @@ -349,7 +492,7 @@ def stamp(site: Path) -> dict: def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, - stamps: dict = None) -> str: + stamps: dict = None, facts: dict = None) -> str: source = page.get("source") if source: body, contents = render_markdown( @@ -363,6 +506,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, config = manifest["site"] blob = config["blob_base"].rstrip("/") + "/" stamps = stamps if stamps is not None else stamp(site) + facts = facts if facts is not None else repo_facts(repo) fields = { "stylesheet": stamps["stylesheet"], "icon": stamps["icon"], @@ -371,7 +515,8 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, or config.get("description", "")), "site_title": escape(config["title"]), "site_tagline": escape(config.get("tagline", "")), - "version": escape(config.get("version", "")), + "version": escape(facts["version"]), + "install_block": facts["install_block"], "body": body, "toc": render_contents(contents), "nav": render_nav(manifest, page), @@ -380,6 +525,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, "section": escape(page.get("section") or ""), "repo_url": config["repo_url"], "issues_url": config.get("issues_url", config["repo_url"]), + "releases_url": config.get("releases_url", config["repo_url"]), "source_url": (blob + source) if source else config["repo_url"], "source_path": escape(source or ""), "canonical": config.get("base_url", "").rstrip("/") + page["path"], @@ -414,6 +560,11 @@ def load_manifest(site: Path) -> dict: for key in ("title", "repo_url", "blob_base"): if key not in manifest["site"]: raise BuildError(f'pages.json: site has no "{key}" key') + if "version" in manifest["site"]: + raise BuildError( + f'pages.json: site has a "version" key, but the version the ' + f"site shows is read from {VERSION_FILE}. Two copies of a " + f"version is one copy too many — remove it.") seen = set() for page in manifest["pages"]: @@ -526,6 +677,7 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None, out = out or (site / "dist") manifest = load_manifest(site) stamps = stamp(site) + facts = repo_facts(repo) # A file in root/ that a route also claims would be silently replaced # by whichever is written last, so it is a build failure instead. @@ -539,9 +691,15 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None, f"{relative}. Rename one — the build will not pick a winner.") pages = [(page, render_page(page, manifest, site=site, repo=repo, - stamps=stamps)) + stamps=stamps, facts=facts)) for page in manifest["pages"]] + # After rendering, before writing: a dead internal link leaves the + # last good build standing, exactly as a renamed heading does. + targets = internal_targets(manifest, site) + for page, html in pages: + check_links(html, page, targets) + clear(out) out.mkdir(parents=True) (out / MARKER).write_text( diff --git a/site/pages.json b/site/pages.json index b96a063..335315a 100644 --- a/site/pages.json +++ b/site/pages.json @@ -2,11 +2,11 @@ "site": { "title": "bench", "tagline": "docs", - "version": "0.2-alpha", "description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.", "base_url": "https://bench.12vectors.com", "repo_url": "https://github.com/12vectors/bench", "issues_url": "https://github.com/12vectors/bench/issues", + "releases_url": "https://github.com/12vectors/bench/releases", "blob_base": "https://github.com/12vectors/bench/blob/main/" }, @@ -15,10 +15,28 @@ "pages": [ { "path": "/", - "title": "bench", + "title": "bench — a live kanban for coding-agent work", "layout": "home", "section": null, - "description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.", + "description": "Task files in stage directories are the only source of truth. A stdlib-only board narrates what happens to them — agents working in git worktrees, PRs opening on review, CI on the cards — and never merges anything itself.", + "source": null + }, + { + "path": "/guides/install/", + "title": "Install and first run", + "layout": "article", + "section": "Guides", + "description": "Untar bench into .task-manager/, run start.sh, and answer the three questions the first run asks.", + "source": "README.md", + "from": "## Install into a repo", + "to": "## Update" + }, + { + "path": "/concepts/stages/", + "title": "The five stages", + "layout": "article", + "section": "Concepts", + "description": "backlog, to-do, in-progress, review, done — the directory a task file sits in is its status, and there is no other source of truth.", "source": "AGENTS.md", "from": "## Stages", "to": "## Moving a task" @@ -33,6 +51,46 @@ "from": "## Claiming a card", "to": "## Syncing boards" }, + { + "path": "/concepts/agents-on-the-board/", + "title": "Agents on the board", + "layout": "article", + "section": "Concepts", + "description": "Start work makes a worktree and a branch, runs the agent headless, and moves the card on the way it exits.", + "source": "AGENTS.md", + "from": "## Agents working the board", + "to": "## Pull requests" + }, + { + "path": "/concepts/pull-requests/", + "title": "PRs and review", + "layout": "article", + "section": "Concepts", + "description": "A card entering review gets a PR opened for it by the board; then review PR, copilot and act on PR, until it settles.", + "source": "AGENTS.md", + "from": "## Pull requests", + "to": "## Stages" + }, + { + "path": "/concepts/team-mode/", + "title": "Team mode", + "layout": "article", + "section": "Concepts", + "description": "BOARD_SYNC makes origin/main the truth and every board a converging replica: moves commit and push themselves, and a beat pulls what the other boards published.", + "source": "AGENTS.md", + "from": "## Syncing boards", + "to": "## Task file format" + }, + { + "path": "/concepts/three-layer-law/", + "title": "The three-layer law", + "layout": "article", + "section": "Concepts", + "description": "Core knows tasks, worktrees, PRs and events. Drivers know apps, adapters know agent vendors, local/ knows your project.", + "source": "README.md", + "from": "## The three-layer law", + "to": "## License" + }, { "path": "/404.html", "title": "Not found", diff --git a/site/static/site.css b/site/static/site.css index d12d9ab..d4b1abe 100644 --- a/site/static/site.css +++ b/site/static/site.css @@ -248,26 +248,25 @@ a:hover{color:var(--text);text-decoration:underline} .dot-alarm{background:var(--ink-alarm)} .dot-calm{background:var(--ink-calm)} .terminal-title{font-size:11px;color:var(--ink-dim);margin-left:6px} +/* The transcript below is real output with the long stretches cut, and + the bar is where it says so — a claim about the block, not decoration. */ +.terminal-note{font-size:10.5px;color:var(--ink-dim);letter-spacing:.04em} .terminal-body{ margin:0;padding:18px;background:var(--ink);color:var(--ink-text); font:var(--t-code)/1.85 var(--mono); - white-space:pre-wrap;word-break:break-all; + /* The release url is 72 characters and has to wrap rather than clip; + `anywhere` breaks it, and breaks a word only when a word is what + does not fit — the transcript's prose lines wrap normally. */ + white-space:pre-wrap;overflow-wrap:anywhere; } .t-calm{color:var(--ink-calm)} .t-accent{color:var(--ink-accent)} .t-muted{color:var(--ink-muted)} .t-dim{color:var(--ink-dim)} -.generated{padding:0 44px 34px} -.page-home .prose{ - background:var(--surface);border:1px solid var(--border-soft); - border-radius:12px;padding:28px 32px 12px; -} -.generated-heading{margin-top:0 !important} -.generated-note{ - margin:0 0 20px;font-size:11px;color:var(--dim);max-width:none; -} - +/* Every door is a link to a page this site builds — the whole card is + the target, so the hover is the card's border rather than an + underline under three words of it. */ .doors{ display:grid;grid-template-columns:repeat(3,1fr);gap:14px; padding:0 44px 34px; @@ -275,9 +274,11 @@ a:hover{color:var(--text);text-decoration:underline} .door{ display:flex;flex-direction:column;gap:8px;padding:20px; background:var(--surface);border:1px solid var(--border-soft); - border-radius:12px; + border-radius:12px;color:var(--text); } +.door:hover{border-color:var(--accent);color:var(--text);text-decoration:none} .door-dark{background:var(--text);border-color:var(--text);color:var(--canvas)} +.door-dark:hover{border-color:var(--accent);color:var(--canvas)} .door-index{font-size:11px;color:var(--dim)} .door-dark .door-index{color:#7fa0a8} .door-title{font:600 19px/1.2 var(--display)} @@ -285,6 +286,26 @@ a:hover{color:var(--text);text-decoration:underline} .door-dark .door-text{color:#a9c4c9} .door .mono{font-size:var(--t-code)} +/* The strip at the foot of the landing page. It carries the version and + nothing else measurable, because the version is the only thing about + itself this site can measure. */ +.strip{ + display:flex;align-items:center;gap:14px;flex-wrap:wrap; + padding:18px 44px;background:var(--sunken); + border-top:1px solid var(--border-soft); +} +.strip-label{ + font-size:var(--t-micro);letter-spacing:.1em;text-transform:uppercase; + color:var(--dim); +} +.strip-version{ + font-size:var(--t-code);padding:4px 10px;background:var(--surface); + border:1px solid var(--border);border-radius:99px;color:var(--accent); +} +.strip-text{font-size:12.5px;line-height:1.55;color:var(--muted);max-width:64ch} +.strip-text .mono{font-size:var(--t-code)} +.strip-link{font-size:12px;color:var(--accent)} + /* ── 404 (layout "notfound") ── */ .lost{ display:flex;flex-direction:column;align-items:flex-start;gap:18px; @@ -304,7 +325,7 @@ a:hover{color:var(--text);text-decoration:underline} .gutter{display:none} .hero{grid-template-columns:1fr;gap:28px;padding:32px 24px} .doors{grid-template-columns:repeat(2,1fr);padding:0 24px 28px} - .generated{padding:0 24px 28px} + .strip{padding:18px 24px} } @media (max-width:760px){ .shell{grid-template-columns:minmax(0,1fr)} diff --git a/site/templates/home.html b/site/templates/home.html index a41bc61..430e165 100644 --- a/site/templates/home.html +++ b/site/templates/home.html @@ -2,20 +2,37 @@ + markup would have to be doubled. --> -$site_title · $site_tagline +$title + + + + + + + @@ -26,7 +43,7 @@ / bench - $version + docs for $version
@@ -50,8 +67,8 @@ $nav working in git worktrees, PRs opening on review, CI on the cards — and never merges anything itself.

- Read the stages - Install into a repo + Install into a repo + bench on GitHub ↗
no account, no service, no database. it is python 3 on your own machine. @@ -62,72 +79,82 @@ $nav ~/your-repo + + first run · abridged -
$$ 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
+    
$install_block
 
-  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]:
+No .task-manager/manager/local/.env yet — a few questions and bench writes one.
+Enter takes the default in [brackets]; Ctrl-D skips the rest.
 
-  Task board for ~/your-repo/.task-manager/tasks
-    http://127.0.0.1:26071/
-    Ctrl-C to stop
- - + solo or team? [solo]: + which agent adapter? [claude]: + what command runs this project's tests? [python3 -m unittest]: -
-
-

The five stages

-

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

-$body +Wrote .task-manager/manager/local/.env — every other setting is in there, commented; edit it any time. + + + +Task board for ~/your-repo/.task-manager/tasks + http://127.0.0.1:26071/ + Ctrl-C to stop
- - - - - - + +
+ + +
+ Version + $version + read from manager/core/VERSION + when this page was built. update.sh replaces an + install with the latest release; BENCH_REF=v2 + pins an exact one. + + Releases ↗