site: the landing page, on things bench really does

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

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

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

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

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

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

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

python3 -m unittest: 407 tests, OK.

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