Merge pull request #29 from 12vectors/task/34-guides-and-concepts-from-agents-md

34 — Generate the guides and concept pages from AGENTS.md
This commit is contained in:
Ronald Ashri
2026-07-31 14:39:15 +02:00
committed by GitHub
10 changed files with 596 additions and 52 deletions
+50 -29
View File
@@ -67,14 +67,19 @@ Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default
a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE`
work|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed
from it) and `wire` (idempotently give the host project live-session
visibility). Headless jobs answer no permission prompts, so each intent is
granted exactly the side effects its prompt demands — commit and test for
work, push for act-pr, posting PR verdicts for review — with the project's
own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral
prefixes each adapter renders in its vendor's rule syntax. Adapters
translate their vendor's events into the board's normalized schema at the
edge — core never sees vendor payloads. The full contract, including the
event schema, lives in `core/adapters/README.md`.
visibility).
Headless jobs answer no permission prompts, so each intent is granted
exactly the side effects its prompt demands commit and test for work,
push for act-pr, posting PR verdicts for review — with the project's own
runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral prefixes
each adapter renders in its vendor's rule syntax.
Adapters translate their vendor's events into the board's normalized
schema at the edge — core never sees vendor payloads. That is what keeps
the board's own code free of any one vendor: swapping adapters swaps the
binary that does the work, not the board. The full contract, including
the event schema, lives in `manager/core/adapters/README.md`.
## Drives
@@ -291,12 +296,14 @@ is a review question, not a state, so the chip takes no colour. A launch
that inherited the vendor default, and a session replayed from disk, wear
no chip at all — the board says nothing rather than guessing.
**▸ start work** launches a headless `claude -p` on the task. It exists only
on `in-progress/` cards: moving a card to in-progress is the commitment, and
only then does work start — the server refuses launches from anywhere else.
In team mode it also refuses a card someone else holds, naming them; the
action reads **▸ take over** there, and firing it is the deliberate
reassignment. An unclaimed card claims itself on launch.
**▸ start work** launches a headless agent run on the task, through the
configured adapter. It exists only on `in-progress/` cards: moving a card
to in-progress is the commitment, and only then does work start — the
server refuses launches from anywhere else. In team mode it also refuses a
card someone else holds, naming them; the action reads **▸ take over**
there, and firing it is the deliberate reassignment. An unclaimed card
claims itself on launch. One agent per task at a time, and a work agent's
worktree must not already exist when it starts.
1. The board creates a git worktree at `.worktrees/<task-stem>/` on a new
branch `task/<task-stem>` from the newest main it can see: with an
@@ -393,13 +400,14 @@ branch — additively, never rebasing or force-pushing — in a dedicated
resolution commit, and refuses semantic ones, naming the collision for a
human to settle. GitHub computes mergeability lazily, so an UNKNOWN
reading keeps the chip's last state rather than flapping.
Tool chips (CI, copilot, PR, drive)
are destinations, not statuses: they live in the card's footer row, never
The card wears that verdict in the design system's state colours:
approved → pine (`--calm`) border and an `approved` pill; changes asked →
terracotta (`--alarm`) and a `changes asked` pill; otherwise it stays the
neutral `waiting on you`. Tool chips (CI, copilot, PR, drive) are
destinations, not statuses: they live in the card's footer row, never
squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌`
while in flight, with hover actions staying in the status pill's slot. The card wears it in the design
system's state colours: approved → pine (`--calm`) border and an
`approved` pill; changes asked → terracotta (`--alarm`) and a
`changes asked` pill; otherwise it stays the neutral `waiting on you`.
while in flight with hover actions staying in the status pill's slot.
Merging remains yours — the board never merges.
The agent's first duty is to judge whether the task is actionable. If the
@@ -438,9 +446,8 @@ clicks needs merge rights on the repo, not just push rights, and a branch
without a PR is refused with a pointer to **↑ open PR** — there is nothing
for origin to merge otherwise. Single-player merges locally, exactly as
above.
One agent per task at a time; a work agent's worktree must not already
exist when starting.
## Stages
The flow is linear:
@@ -448,8 +455,6 @@ The flow is linear:
backlog → to-do → in-progress → review → done
```
## Stages
### backlog/
Where new tasks are written and where they wait. A backlog task may be rough,
incomplete, or fully specified — what it has in common with its neighbours is
@@ -485,7 +490,7 @@ Supporting documents that tasks can link to — external specs, API documentatio
research notes, screenshots, competitive analysis, regulatory references, etc.
These don't move through the workflow; they're stable resources. Reference them
from task files using relative links — two levels up from a stage directory
(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`).
(e.g. `[the payments API spec](../../reference/payments-api.md)`).
## Moving a task
@@ -529,10 +534,10 @@ messaged `board: <number> → <stage> (<name>)`, staged by pathspec so
unrelated staged work is neither committed nor unstaged (hooks are skipped —
this is bookkeeping, not code). Pushing is not part of it: those commits sit
on your local `main` until you push it (or until `BOARD_SYNC` pushes them —
see below), which the PR guard above will tell you about if you forget. The
setting is off by default: a single-player
board neither writes nor clears the assignee and makes no commits, exactly
as before, and `tasks/` is committed by hand. The gate governs only whether
see "Syncing boards"), which the guard in "Pull requests" will tell you
about if you forget. The setting is off by default: a single-player
board neither writes nor clears the assignee and makes no commits, and
`tasks/` is committed by hand. The gate governs only whether
a *move* writes the line — an **Assignee:** added to a file by hand is still
read and shown on the card whether the gate is on or off.
@@ -634,6 +639,22 @@ and keep the status in step with the directory the file sits in. Priority may
carry a short justification after the level
(e.g. `Medium — foundational for any real environment`).
Every header field, and who writes it:
| Field | Required | Value | Written by |
| --- | --- | --- | --- |
| **Status** | yes | `Backlog` · `To Do` · `In Progress` · `Review` · `Done` (`Archived` for a card in `tasks/archive/`) | you, or the board on a move |
| **Priority** | yes | `High` · `Medium` · `Low`, optionally followed by a short justification | you |
| **Type** | no | `Discovery` · `Bug` · `Feature` · `Refactor` · `Chore` | you |
| **Assignee** | no | a name, taken from `git config user.name` | the board on a claiming move, or you by hand |
| **Depends on** | no | task numbers or external preconditions, comma-separated | you |
| **PR** | no | the pull request url | the board when it opens one |
**Status** is the field the board holds you to: a header that disagrees
with the directory the file sits in is flagged `status drift`.
**Assignee** and **PR** it writes and reads itself. The rest are for
whoever picks the next card.
An optional **Type** line can record what kind of work the task is, when that
isn't obvious from the title:
+9 -3
View File
@@ -70,9 +70,15 @@ install one-liner above depends on.
## The three-layer law
Core knows about tasks, worktrees, PRs and events. It knows nothing about
any particular app (drivers do: `local/driver/start`), agent vendor
(adapters do: `core/adapters/`), or project (`local/` does). Full docs in
AGENTS.md; the adapter contract in `manager/core/adapters/README.md`.
any particular app (drivers do: `manager/local/driver/start`), agent
vendor (adapters do: `manager/core/adapters/`), or project
(`manager/local/` does).
The consequence is what makes an update safe: `update.sh` replaces
`manager/core/` wholesale, and everything a project taught bench about
itself lives outside it. Full docs in [AGENTS.md](AGENTS.md); the adapter
contract in
[manager/core/adapters/README.md](manager/core/adapters/README.md).
## License
+24 -6
View File
@@ -18,6 +18,13 @@ 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`.
An article page authors one sentence of its own — the lede under the
title — because a slice starts mid-document and a reader arriving from
the nav is owed a line saying what they are looking at. That is the whole
allowance. When a section reads badly on the web, the fix is the section:
edit `AGENTS.md` so it reads well in both places rather than forking the
prose into this directory.
```bash
python3 -m pip install -r site/requirements.txt # once
python3 site/fetch-fonts.py # once, needs network
@@ -150,21 +157,32 @@ answers rather than files:
written to exactly that path — `/404.html` is the only one, and it
exists because the host looks for that literal filename.
- **`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. A `null` section keeps the page out of both,
which is how the 404 page stays off the nav.
- **`section`** groups the page in the nav and the sidebar, and puts it
on the reading order prev/next walks. The IA is read out of this file
in this file's order — nothing is derived from the directory layout. A
`null` section keeps the page out of the nav, the sidebar and the flow,
which is how the 404 page stays off all three.
- **`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.
Headings inside fenced code blocks never match. It is also what
"Edit this page on GitHub" anchors to, so the link opens the section
rather than the top of a 700-line file.
- **`to`** is optional. Without it the slice runs to the next heading of
the same level or shallower.
- **`description`** is the page's meta description, and doubles as the
visible lede under the title.
- **`lede`** is optional, and only worth setting when the sentence a
reader should see differs from the one a search engine should. It is
the only prose a page may author.
The `from` heading itself is dropped — the layout renders the page title
— and what remains is promoted by `level - 1`, so a section's `###`
sub-headings land as the page's `<h2>`s.
sub-headings land as the page's `<h2>`s. Promotion stops at `<h2>`: a
slice that deliberately runs past its own section ("Stages" through
"Moving a task") carries headings at the `from` level, and those become
`<h2>` peers rather than a second `<h1>` on a page that already has one.
## What fails the build
+85 -3
View File
@@ -40,6 +40,13 @@ 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`).
An article page authors exactly one sentence of its own: the lede under
the title (`$lede`, the manifest's `lede` or its `description`). A slice
begins mid-document, so a reader arriving from the nav is owed a line
saying what they are looking at but that is the whole allowance, and a
manifest entry that tried to carry a body would still have nowhere to put
it.
## What the host needs from the build
Two things here exist for the way the site is served (site/wrangler.jsonc,
@@ -151,13 +158,20 @@ def find_heading(marks: list, value: str, after: int = -1):
def promote(text: str, by: int) -> str:
"""Shift every heading in a slice `by` levels shallower, so a section
lifted out of a larger document keeps its internal hierarchy while
starting at <h2> under the page's own <h1>."""
starting at <h2> under the page's own <h1>.
Never above <h2>. A slice that runs past the end of its own section
"Stages" through "Moving a task", one page about one idea carries
headings at the `from` heading's own level, and those would promote to
a second <h1> on a page that already has one. They land beside the
section's children as <h2> instead: on the page they are peers, which
is what putting them on one page said."""
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()
lines[index] = "#" * max(2, level - by) + " " + found.group(2).strip()
return "\n".join(lines)
@@ -281,6 +295,17 @@ def repo_facts(repo: Path) -> dict:
# ── links ─────────────────────────────────────────────────────────────
def github_anchor(heading: str) -> str:
"""GitHub's own anchor for a heading, so "Edit this page" lands on the
section the page was cut from rather than at the top of a 700-line
file. GitHub lowercases, drops punctuation that is not a hyphen or an
underscore, and turns spaces into hyphens which is not quite
slugify()'s rule (that one collapses runs), so it is written out here
rather than shared."""
text = heading.lstrip("#").strip().lower()
return re.sub(r"[^\w\- ]", "", text).replace(" ", "-")
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
@@ -442,6 +467,43 @@ def render_sidebar(manifest: dict, current: dict) -> str:
return "\n".join(out)
def flow(manifest: dict) -> list:
"""The pages in reading order — the sidebar, flattened. Prev/next walks
this list, so what the arrows do and what the sidebar shows cannot
disagree. A page with no section (the landing page, the 404) is not on
the flow and gets no arrows."""
return [page for group in sections(manifest) for page in group["pages"]]
def render_flow(manifest: dict, current: dict) -> str:
"""The two arrows at the foot of an article. Absent neighbours keep
their slot as an empty span, so `next` stays on the right on the first
page exactly as it does on every other."""
order = flow(manifest)
here = next((index for index, page in enumerate(order)
if page["path"] == current["path"]), None)
if here is None:
return ""
neighbours = (
(order[here - 1] if here > 0 else None, "prev", "← previous"),
(order[here + 1] if here + 1 < len(order) else None, "next", "next →"),
)
if not any(page for page, _, _ in neighbours):
return ""
out = ['<nav class="flow">']
for page, direction, label in neighbours:
if not page:
out.append('<span class="spacer"></span>')
continue
out.append(f'<a class="flow-link flow-{direction}" '
f'href="{page["path"]}">'
f'<span class="mono flow-dir">{label}</span>'
f'<span class="flow-title">{escape(page["title"])}</span>'
f"</a>")
out.append("</nav>")
return "\n".join(out)
def render_contents(contents: list) -> str:
if not contents:
return ""
@@ -507,12 +569,31 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
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)
# "Edit this page" is a promise that the reader lands on the thing that
# is wrong. For a sliced page that is the section, not the file: an
# anchor built from the same `from` heading the slice starts at, so the
# two cannot point at different places.
source_url = config["repo_url"]
if source:
source_url = blob + source
anchor = github_anchor(page["from"])
if anchor:
source_url += "#" + anchor
fields = {
"stylesheet": stamps["stylesheet"],
"icon": stamps["icon"],
"title": escape(page["title"]),
"description": escape(page.get("description")
or config.get("description", "")),
# The design's lede. It is the one sentence a page is allowed to
# author, because a slice starts mid-document and a reader arriving
# from the nav needs to be told what they are looking at; the
# manifest's own `description` says that already, so `lede` only
# exists for the pages where the two want different words.
"lede": escape(page.get("lede") or page.get("description")
or config.get("description", "")),
"site_title": escape(config["title"]),
"site_tagline": escape(config.get("tagline", "")),
"version": escape(facts["version"]),
@@ -521,12 +602,13 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
"toc": render_contents(contents),
"nav": render_nav(manifest, page),
"sidebar": render_sidebar(manifest, page),
"flow": render_flow(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"]),
"releases_url": config.get("releases_url", config["repo_url"]),
"source_url": (blob + source) if source else config["repo_url"],
"source_url": source_url,
"source_path": escape(source or ""),
"canonical": config.get("base_url", "").rstrip("/") + page["path"],
}
+21 -1
View File
@@ -39,7 +39,16 @@
"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"
"to": "## Claiming a card"
},
{
"path": "/concepts/task-files/",
"title": "Task files",
"layout": "article",
"section": "Concepts",
"description": "A task is a markdown file with a numbered name and a short header. Status is the only field the board enforces.",
"source": "AGENTS.md",
"from": "## Task file format"
},
{
"path": "/concepts/claiming-a-card/",
@@ -91,6 +100,17 @@
"from": "## The three-layer law",
"to": "## License"
},
{
"path": "/concepts/adapters/",
"title": "Agent adapters",
"layout": "article",
"section": "Concepts",
"description": "Headless jobs run through an adapter, so the board works with coding agents other than Claude Code — and never sees a vendor's payloads.",
"lede": "The adapter is the layer that knows a coding agent. It launches one headless job, and translates that vendor's events into the board's own schema — which is what keeps every other line of core free of any particular agent.",
"source": "AGENTS.md",
"from": "## Agent adapters",
"to": "## Drives"
},
{
"path": "/404.html",
"title": "Not found",
+24
View File
@@ -166,6 +166,13 @@ a:hover{color:var(--text);text-decoration:underline}
/* ── generated prose ── */
.page-article .prose{padding:30px 40px 44px;min-width:0}
/* The lede is the article's own sentence; everything after it is the
slice. It reads wider and quieter than body copy, as in the design. */
.prose-lede{
margin:0 0 26px;max-width:60ch;
font-size:var(--t-lede);line-height:1.6;color:var(--muted);
text-wrap:pretty;
}
.prose h1{
font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em;
margin:0 0 12px;
@@ -218,6 +225,23 @@ a:hover{color:var(--text);text-decoration:underline}
.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}
/* Prev/next along the same order the sidebar shows. An absent neighbour
leaves a .spacer in its slot, so `next ` stays right-hand on the first
page exactly as it does on every other. */
.flow{
display:flex;gap:14px;margin-top:44px;padding-top:22px;
border-top:1px solid var(--border-soft);
}
.flow-link{
display:flex;flex-direction:column;gap:5px;flex:0 1 auto;max-width:46%;
padding:12px 16px;background:var(--surface);
border:1px solid var(--border-soft);border-radius:11px;color:var(--text);
}
.flow-link:hover{border-color:var(--accent);color:var(--text);text-decoration:none}
.flow-next{margin-left:auto;text-align:right}
.flow-dir{font-size:var(--t-micro);letter-spacing:.08em;color:var(--dim)}
.flow-title{font:600 15px/1.3 var(--display)}
/* ── home layout (1b Dockside) ── */
.hero{
display:grid;grid-template-columns:1.05fr .95fr;gap:44px;
+6
View File
@@ -51,7 +51,13 @@ $sidebar
<article class="prose">
<div class="crumbs">$breadcrumb</div>
<h1>$title</h1>
<!-- The one sentence an article authors. Everything below it is the
body placeholder: a heading slice of the file named in the
gutter. (Placeholders substitute inside comments too, so this
one cannot spell that name out.) -->
<p class="prose-lede">$lede</p>
$body
$flow
</article>
<aside class="gutter">
+15 -7
View File
@@ -37,9 +37,12 @@ def builder():
BUILDER = builder()
# The files a build reads out of the repo: the two the pages are cut
# from, and the one the version is read from. A scratch repo needs these
# and nothing else to build the real manifest.
SOURCES = ["AGENTS.md", "README.md", "manager/core/VERSION"]
# from, the one the version is read from, and every file a markdown link
# inside a slice resolves to — the builder checks those exist, so a
# scratch repo without them fails for a reason that has nothing to do
# with the test.
SOURCES = ["AGENTS.md", "README.md", "manager/core/VERSION",
"manager/core/adapters/README.md"]
# A layout with no markup of its own, written into a scratch site when a
# test wants to exercise the builder rather than a shipped template.
@@ -250,15 +253,20 @@ class DriftStopsTheBuild(ScratchCase):
break the build, not empty a page."""
def test_a_renamed_heading_names_the_route_and_the_heading(self):
self.repo.edit("AGENTS.md", "## Claiming a card",
"## Claiming a task card")
"""The heading renamed here is one exactly one manifest entry
names. A heading that is also the *end* of the page above it
most of them are, the document being a chain would be reported
against whichever route the build reaches first, which is true but
makes a poor test of "names the route"."""
self.repo.edit("AGENTS.md", "## Agents working the board",
"## Agents at work on the board")
result = self.repo.build()
self.assertNotEqual(result.returncode, 0,
"a renamed heading built cleanly")
self.assertIn("/concepts/claiming-a-card/", result.stderr)
self.assertIn("/concepts/agents-on-the-board/", result.stderr)
self.assertIn("AGENTS.md", result.stderr)
self.assertIn("## Claiming a card", result.stderr)
self.assertIn("## Agents working the board", result.stderr)
def test_a_renamed_heading_emits_no_page_at_all(self):
"""Not "a page with an empty body" — nothing is written. Every
+7 -3
View File
@@ -318,9 +318,13 @@ class Scratch(unittest.TestCase):
self.addCleanup(shutil.rmtree, self.root, True)
shutil.copytree(SITE, self.root / "site",
ignore=shutil.ignore_patterns("dist", "__pycache__"))
# The markdown the pages are cut from, plus the file the version
# shown on them is read from.
for name in ("AGENTS.md", "README.md", "manager/core/VERSION"):
# The markdown the pages are cut from, the file the version shown
# on them is read from, and whatever a link inside a slice points
# at. One list, in tests/test_site_build.py — a scratch repo that
# is missing one of them fails for a reason no test here is about.
from tests.test_site_build import SOURCES
for name in SOURCES:
(self.root / name).parent.mkdir(parents=True, exist_ok=True)
shutil.copy(REPO / name, self.root / name)
self.out = self.root / "site" / "dist"
+355
View File
@@ -0,0 +1,355 @@
"""The guides and concept pages: the middle of the site, and the half of
the promise tests/test_site_build.py does not cover.
That file is about the generator a renamed heading stops the build, a
dead link stops the build. This one is about what a reader actually gets
once it has run: the 1a Harbour furniture around each slice (sidebar,
on-this-page, prev/next, "Edit this page"), and the three markdown
constructs the sources really contain rendering as themselves rather than
as escaped text.
The rule underneath all of it: no page body is authored twice. A page
authors its title and one lede sentence; everything else on it was cut
out of AGENTS.md or README.md by site/pages.json.
python3 -m unittest discover -s tests
"""
import json
import re
import shutil
import tempfile
import unittest
from pathlib import Path
from tests.test_site_build import (BUILDER, HAS_MARKDOWN_IT, REPO, SITE,
ScratchCase, needs_renderer, run_build)
# The two the layout owes a reader at the foot of every article.
FLOW_LINK = re.compile(
r'class="flow-link flow-(prev|next)" href="([^"]+)"')
HEADING_ID = re.compile(r'<h2 id="([^"]+)"')
TOC_LINK = re.compile(r'class="toc-link" href="#([^"]+)"')
SIDE_HERE = re.compile(r'class="side-link side-here" href="([^"]+)"')
GUTTER_LINK = re.compile(r'class="gutter-link" href="([^"]+)"')
DOOR = re.compile(r'class="door[^"]*" href="([^"]+)"')
LEDE = re.compile(r'<p class="prose-lede">(.*?)</p>', re.S)
class BuiltSite(unittest.TestCase):
"""The real manifest, built once into a scratch directory."""
@classmethod
def setUpClass(cls):
if not HAS_MARKDOWN_IT:
raise unittest.SkipTest("markdown-it-py is not installed")
cls.out = Path(tempfile.mkdtemp(prefix="bench-pages-")).resolve()
cls.result = run_build(REPO, cls.out)
if cls.result.returncode != 0: # not assert: must survive python -O
raise RuntimeError(
f"site/build.py failed:\n{cls.result.stdout}"
f"{cls.result.stderr}")
cls.manifest = json.loads(
(SITE / "pages.json").read_text(encoding="utf-8"))
cls.flow = BUILDER.flow(cls.manifest)
@classmethod
def tearDownClass(cls):
if hasattr(cls, "out"):
shutil.rmtree(cls.out, ignore_errors=True)
def page(self, route: str) -> str:
return BUILDER.target_for(self.out, route).read_text("utf-8")
def articles(self) -> list:
"""Every entry on the flow — the guides and the concepts, which
are exactly the pages this task built."""
return list(self.flow)
class EveryRouteRendersInTheArticleLayout(BuiltSite):
"""Acceptance: the routes render in 1a, and every body on them is a
slice rather than something a person typed into the site."""
def test_the_seven_concepts_and_the_install_guide_are_all_there(self):
"""Named one by one rather than counted: a route quietly dropped
from the manifest is exactly the failure this catches."""
routes = {page["path"] for page in self.articles()}
for route in ("/guides/install/",
"/concepts/stages/",
"/concepts/task-files/",
"/concepts/claiming-a-card/",
"/concepts/agents-on-the-board/",
"/concepts/pull-requests/",
"/concepts/team-mode/",
"/concepts/three-layer-law/",
"/concepts/adapters/"):
self.assertIn(route, routes)
self.assertTrue(BUILDER.target_for(self.out, route).is_file(),
f"{route} produced no page")
def test_each_one_is_the_three_column_layout(self):
for entry in self.articles():
html = self.page(entry["path"])
self.assertEqual("article", entry["layout"], entry["path"])
for furniture in ('class="page-article"', 'class="side"',
'class="prose"', 'class="gutter"',
'class="crumbs"'):
self.assertIn(furniture, html,
f'{entry["path"]} is missing {furniture}')
def test_no_body_is_authored_twice(self):
"""Every article names a source file and a heading to cut from.
The lede is the single exception, and it is one sentence in the
manifest not a body."""
for entry in self.articles():
self.assertTrue(entry.get("source"),
f'{entry["path"]} has no source')
self.assertTrue(entry.get("from"),
f'{entry["path"]} has no from heading')
def test_the_lede_is_present_and_is_the_manifests_own_sentence(self):
for entry in self.articles():
found = LEDE.search(self.page(entry["path"]))
self.assertIsNotNone(found, f'{entry["path"]} has no lede')
wanted = entry.get("lede") or entry["description"]
self.assertEqual(wanted.strip(),
found.group(1).replace("&#x27;", "'").strip())
class TheSidebarAndTheContentsFollowThePage(BuiltSite):
"""The left rail says where you are in the site; the right rail says
where you are in the page. Neither is authored: one is the manifest,
the other is the promoted slice's own h2s."""
def test_the_sidebar_marks_exactly_the_page_you_are_on(self):
for entry in self.articles():
here = SIDE_HERE.findall(self.page(entry["path"]))
self.assertEqual([entry["path"]], here,
f'{entry["path"]} does not mark itself in the '
f"sidebar")
def test_the_sidebar_lists_every_other_page_too(self):
html = self.page("/concepts/stages/")
for entry in self.articles():
self.assertIn(f'href="{entry["path"]}"', html,
f'the sidebar has no link to {entry["path"]}')
def test_the_header_nav_marks_the_section(self):
self.assertIn('class="nav-link nav-here"',
self.page("/concepts/stages/"))
def test_on_this_page_is_the_bodys_own_h2s_in_order(self):
"""Not a subset and not a superset: the same anchors, the same
order. A slice that grows a sub-heading grows a contents entry
with nobody editing the site."""
for entry in self.articles():
html = self.page(entry["path"])
self.assertEqual(HEADING_ID.findall(html), TOC_LINK.findall(html),
f'{entry["path"]}: the contents list and the '
f"headings disagree")
def test_a_page_with_sub_headings_really_has_a_contents_list(self):
"""Guards the test above against passing on two empty lists."""
html = self.page("/concepts/stages/")
self.assertIn("On this page", html)
self.assertIn('href="#backlog"', html)
self.assertIn('href="#moving-a-task"', html)
class PrevAndNextWalkTheFlow(BuiltSite):
"""The arrows follow the sidebar's order, because a reader who used
one and then the other must not be sent somewhere else."""
def links(self, route: str) -> dict:
return dict((direction, target) for direction, target
in FLOW_LINK.findall(self.page(route)))
def test_each_page_points_at_its_neighbours(self):
order = [entry["path"] for entry in self.flow]
for index, route in enumerate(order):
found = self.links(route)
self.assertEqual(order[index - 1] if index else None,
found.get("prev"), f"{route}: wrong previous")
self.assertEqual(order[index + 1] if index + 1 < len(order)
else None, found.get("next"),
f"{route}: wrong next")
def test_the_ends_of_the_flow_have_one_arrow_each(self):
first, last = self.flow[0]["path"], self.flow[-1]["path"]
self.assertNotIn("prev", self.links(first))
self.assertIn("next", self.links(first))
self.assertIn("prev", self.links(last))
self.assertNotIn("next", self.links(last))
def test_an_absent_neighbour_keeps_its_slot(self):
"""`next →` sits on the right on the first page as on every
other, which is a spacer in the markup rather than a rule in the
stylesheet."""
html = self.page(self.flow[0]["path"])
flow = html[html.index('<nav class="flow">'):]
self.assertLess(flow.index('<span class="spacer">'),
flow.index("flow-link"))
def test_the_landing_page_and_the_404_are_not_on_the_flow(self):
"""They have no section, so they are not steps in a reading
order and an article that linked "previous: not found" would be
a strange thing to ship."""
off = [page["path"] for page in self.manifest["pages"]
if not page.get("section")]
self.assertEqual({"/", "/404.html"}, set(off))
for route in off:
self.assertNotIn('<nav class="flow">', self.page(route))
class EditThisPageOpensTheSection(BuiltSite):
"""A reader who spots a mistake has to land on the file that is
actually wrong and, on a 700-line brief, at the section that is."""
def test_it_names_the_source_file_and_its_section(self):
blob = self.manifest["site"]["blob_base"].rstrip("/") + "/"
for entry in self.articles():
wanted = (blob + entry["source"] + "#"
+ BUILDER.github_anchor(entry["from"]))
self.assertIn(f'href="{wanted}"', self.page(entry["path"]),
f'{entry["path"]}: "Edit this page" does not open '
f'{entry["source"]} at {entry["from"]}')
def test_the_anchor_is_the_one_github_gives_that_heading(self):
"""Spot-checked against the real headings rather than only
against the function that made them."""
self.assertEqual("claiming-a-card",
BUILDER.github_anchor("## Claiming a card"))
self.assertEqual("the-three-layer-law",
BUILDER.github_anchor("## The three-layer law"))
self.assertEqual("install-into-a-repo",
BUILDER.github_anchor("## Install into a repo"))
self.assertEqual("state-syncs-reactions-dont",
BUILDER.github_anchor("### State syncs; reactions "
"don't"))
def test_the_gutter_also_offers_the_issue_tracker(self):
links = GUTTER_LINK.findall(self.page("/concepts/stages/"))
self.assertIn(self.manifest["site"]["issues_url"], links)
def test_the_authored_pages_point_at_the_repository_instead(self):
"""The landing page is not a slice, so there is no section to
send anyone to."""
for entry in self.manifest["pages"]:
if entry.get("source"):
continue
self.assertNotIn("#", self.page(entry["path"]).split(
'class="gutter-link" href="')[-1].split('"')[0])
class TheDoorsOpenOntoArticles(BuiltSite):
"""Task 33 put six doors on the landing page. This is the other end of
them."""
def test_every_door_lands_on_a_page_in_the_flow(self):
routes = {entry["path"] for entry in self.flow}
doors = DOOR.findall(self.page("/"))
self.assertEqual(6, len(doors))
for door in doors:
self.assertIn(door, routes,
f"the door to {door} opens onto nothing")
@needs_renderer
class MarkdownComesOutAsMarkup(BuiltSite):
"""The edge case: a table, a fenced code block and a nested list have
to render as themselves. The first two are in the repo's own slices —
the header-field table in "Task file format", the command blocks in
the install guide and the stage diagram in "Stages". A nested list is
not, so ARenderedSliceKeepsItsShape below builds one on purpose rather
than pretending this suite covers it."""
def test_a_table_renders_as_a_table(self):
html = self.page("/concepts/task-files/")
self.assertIn("<table>", html)
self.assertIn("<th>Field</th>", html)
self.assertIn("<td><strong>Status</strong></td>", html)
self.assertNotIn("| Field |", html)
def test_a_fenced_block_renders_as_a_code_block(self):
install = self.page("/guides/install/")
self.assertIn("<pre><code", install)
self.assertIn("mkdir .task-manager", install)
self.assertIn("backlog → to-do → in-progress → review → done",
self.page("/concepts/stages/"))
def test_a_fenced_heading_is_not_mistaken_for_a_heading(self):
""""Task file format" fences a task file starting `# Task title`.
It has to arrive as code, and it must not have sliced the page."""
html = self.page("/concepts/task-files/")
self.assertIn("# Task title", html)
self.assertNotIn("<h1>Task title</h1>", html)
self.assertIn("**Depends on:** 03, 05", html)
@needs_renderer
class ARenderedSliceKeepsItsShape(ScratchCase):
"""All three constructs in one slice, so the renderer is tested on the
shapes rather than on the sections that happen to have them today."""
BODY = """
| Stage | What it means |
| --- | --- |
| `review/` | built, not yet trusted |
```bash
./.task-manager/start.sh
```
- the board
- narrates moves
- opens PRs
- and never merges
"""
def test_a_table_a_fence_and_a_nested_list_all_survive(self):
(self.repo.root / "SOURCE.md").write_text(
f"# Doc\n\n## Section\n{self.BODY}\n", encoding="utf-8")
self.repo.pages({
"path": "/shapes/", "title": "Shapes", "layout": "article",
"section": "Concepts", "description": "one of each.",
"source": "SOURCE.md", "from": "## Section",
})
result = self.repo.build()
self.assertEqual(0, result.returncode, result.stderr)
html = (self.repo.out / "shapes" / "index.html").read_text("utf-8")
self.assertIn("<th>Stage</th>", html)
self.assertIn("<code>review/</code>", html)
self.assertIn("<pre><code", html)
self.assertIn("start.sh", html)
# The nested list: a <ul> inside an <li>, not two flat lists.
self.assertRegex(html, r"<li>the board\s*<ul>")
self.assertIn("<li>opens PRs</li>", html)
@needs_renderer
class DriftOnARealConceptPage(ScratchCase):
"""Inherited from task 31 and worth asserting on a page that ships:
a section renamed in AGENTS.md stops the build, naming the route."""
def test_renaming_task_file_format_names_its_route(self):
self.repo.edit("AGENTS.md", "## Task file format",
"## The task file")
result = self.repo.build()
self.assertNotEqual(0, result.returncode,
"a renamed section built cleanly")
self.assertIn("/concepts/task-files/", result.stderr)
self.assertIn("## Task file format", result.stderr)
self.assertFalse(self.repo.out.exists(),
"a failed build wrote pages anyway")
def test_renaming_agent_adapters_names_its_route(self):
self.repo.edit("AGENTS.md", "## Agent adapters", "## Adapters")
result = self.repo.build()
self.assertNotEqual(0, result.returncode)
self.assertIn("/concepts/adapters/", result.stderr)
if __name__ == "__main__":
unittest.main()