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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 && 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 & 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">
|
||||
|
||||
Reference in New Issue
Block a user