merge main into task/35: keep both rails' folded strips and 34's lede

article.html: 34 added the lede under the h1, 35 added the folded
contents strip in the same place. Both belong, and site.css settles the
order — its lede adjacency names .menu-contents + p, so the strip sits
between the title and the lede.

Two things the merge exposed rather than caused:

- $toc and $sidebar were spelled inside article.html's own comments,
  and string.Template substitutes there too, so every page carried a
  second copy of both rails inside a comment (28 toc links for 7
  headings). Doubled to $$toc / $$sidebar, as the template contract
  already said to.
- test_site_pages assumed one rendering of each rail. Both are now
  written twice by design, so the tests assert every rendering agrees
  with the page instead of assuming there is one.

The card file keeps main's location: the board owns a card's stage, and
main had already moved 35 to review/.
This commit is contained in:
istos
2026-07-31 15:27:00 +02:00
32 changed files with 2167 additions and 153 deletions
+2
View File
@@ -5,3 +5,5 @@ __pycache__/
.claude/settings.local.json
.DS_Store
site/dist/
# wrangler's local cache, written by a deploy from this checkout
.wrangler/
+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
+73 -11
View File
@@ -447,6 +447,17 @@
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
#drawer .dbody h3{font-size:13px;margin:14px 0 4px}
#drawer .dbody p,#drawer .dbody li{font-size:12.5px;color:var(--muted)}
#drawer .dbody ul,#drawer .dbody ol{margin:6px 0;padding-left:18px}
#drawer .dbody li{margin:3px 0}
#drawer .dbody li>ul,#drawer .dbody li>ol{margin:3px 0}
/* task-list items: a glyph, not an input — the file is the source of truth */
#drawer .dbody li.tick{list-style:none;margin-left:-18px}
#drawer .dbody li.tick .box{
display:inline-block;width:11px;height:11px;margin-right:7px;
border:1px solid var(--border);border-radius:3px;
font-size:9px;line-height:10px;text-align:center;
}
#drawer .dbody li.tick.on .box{border-color:var(--calm);color:var(--calm)}
#drawer .dbody strong{color:var(--text)}
#drawer .dbody code{font-family:var(--mono);font-size:11.5px;background:var(--sunken);padding:1px 4px;border-radius:3px}
#drawer .dbody pre{background:var(--sunken);border-radius:8px;padding:10px 12px;overflow-x:auto}
@@ -1900,7 +1911,8 @@ async function openExtra(dir, name) {
}
}
// Small markdown renderer — enough for these task files, no dependencies.
// Small markdown renderer — for these task files, which are hard-wrapped
// prose: a logical line spans several source lines. No dependencies.
function md(src) {
const blocks = esc(src).split(/\n{2,}/);
const inline = (t) => t
@@ -1909,6 +1921,56 @@ function md(src) {
.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
/* Lists: group physical lines into logical items before rendering. A new
item begins only at a marker; a line without one is continuation text
joined to the item above with a space — that is what stops a wrapped
item from sprouting a second bullet. A marker indented past the level
it sits in opens a nested list, and a shallower one closes back to the
level that fits, so children indent under their parent instead of
flattening beside it. */
const MARKER = /^([ \t]*)(?:[-*]|\d+\.)[ \t]+(.*)$/;
const ORDERED = /^[ \t]*\d+\./;
const TICK = /^\[([ xX])\]\s*(.*)$/; // - [ ] / - [x], read-only here
const listTree = (lines) => {
const root = { ordered: ORDERED.test(lines[0]), items: [] };
const stack = [{ indent: null, list: root }];
let last = null;
for (const line of lines) {
const m = line.match(MARKER);
if (!m) { // continuation of the item above
if (last && line.trim()) last.text += ' ' + line.trim();
continue;
}
const indent = m[1].replace(/\t/g, ' ').length;
const top = () => stack[stack.length - 1];
if (top().indent === null) top().indent = indent;
while (stack.length > 1 && indent < top().indent) stack.pop();
if (last && indent > top().indent) {
last.sub = { ordered: ORDERED.test(line), items: [] };
stack.push({ indent, list: last.sub });
}
last = { text: m[2], sub: null };
top().list.items.push(last);
}
return root;
};
const listHtml = (list) => {
const tag = list.ordered ? 'ol' : 'ul';
return `<${tag}>` + list.items.map((it) => {
const t = it.text.match(TICK);
const body = inline(t ? t[2] : it.text) + (it.sub ? listHtml(it.sub) : '');
if (!t) return `<li>${body}</li>`;
// A glyph, never an input: the task file is the source of truth and
// the drawer is not an editor. Done reads as settled, open is neutral.
const done = t[1] !== ' ';
return `<li class="tick${done ? ' on' : ''}">` +
`<span class="box" role="img" aria-label="${done ? 'done' : 'not done'}">` +
`${done ? '✓' : ''}</span>${body}</li>`;
}).join('') + `</${tag}>`;
};
let inFence = false, fenced = [];
const out = [];
for (const block of blocks) {
@@ -1923,7 +1985,8 @@ function md(src) {
}
continue;
}
const lines = block.split('\n');
const text = block.replace(/\s+$/, ''); // the split leaves the last block a trailing newline
const lines = text.split('\n');
if (lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[1])) {
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map(c => inline(c.trim()));
const head = cells(lines[0]);
@@ -1932,21 +1995,20 @@ function md(src) {
head.map(h => `<th>${h}</th>`).join('') + '</tr></thead><tbody>' +
rows.map(r => '<tr>' + r.map(c => `<td>${c}</td>`).join('') + '</tr>').join('') +
'</tbody></table></div>');
} else if (/^#{1,6}\s/.test(block)) {
} else if (/^#{1,6}\s/.test(text)) {
out.push(lines.map(l => {
const m = l.match(/^(#{1,6})\s+(.*)$/);
return m ? `<h${m[1].length}>${inline(m[2])}</h${m[1].length}>` : `<p>${inline(l)}</p>`;
}).join(''));
} else if (/^\s*[-*]\s/.test(block)) {
out.push('<ul>' + lines.map(l => `<li>${inline(l.replace(/^\s*[-*]\s+/, ''))}</li>`).join('') + '</ul>');
} else if (/^\s*\d+\.\s/.test(block)) {
out.push('<ol>' + lines.map(l => `<li>${inline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`).join('') + '</ol>');
} else if (/^&gt;/.test(block)) {
out.push('<blockquote>' + inline(block.replace(/^&gt;\s?/gm, '')).replace(/\n/g, '<br>') + '</blockquote>');
} else if (/^-{3,}$/.test(block.trim())) {
} else if (MARKER.test(lines[0])) {
out.push(listHtml(listTree(lines)));
} else if (/^&gt;/.test(text)) {
// reflow, don't preserve the author's wrap column
out.push('<blockquote>' + inline(text.replace(/^&gt;\s?/gm, '')).replace(/\n/g, ' ') + '</blockquote>');
} else if (/^-{3,}$/.test(text.trim())) {
out.push('<hr>');
} else {
out.push(`<p>${inline(block).replace(/\n/g, '<br>')}</p>`);
out.push(`<p>${inline(text).replace(/\n/g, ' ')}</p>`);
}
}
if (fenced.length) out.push(`<pre><code>${fenced.join('\n\n')}</code></pre>`);
+30 -11
View File
@@ -18,13 +18,22 @@ 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
python3 site/build.py # → site/dist/
```
`site/dist/` is gitignored — it is output, rebuilt on every deploy.
`site/dist/` is gitignored — it is output, rebuilt on every deploy. The
woff2 files under `static/fonts/` are not: they are committed, so a
clean checkout builds the real typography without asking anyone for it,
and a deploy is the same bytes from any machine.
`site/` never reaches a host repo: releases ship exactly what
`../manager/core/release-manifest` lists, and it does not list this
@@ -77,13 +86,12 @@ separate decision with a separate cost, and a follow-up card.
## Deploying
From a clean checkout, four commands. Re-running the whole sequence is
From a clean checkout, three commands. Re-running the whole sequence is
safe: the build empties and rewrites `dist/`, and `wrangler deploy`
replaces the Worker's assets rather than adding to them.
```bash
python3 -m pip install -r site/requirements.txt # once per machine
python3 site/fetch-fonts.py # once per checkout
python3 site/build.py # → site/dist/
npx wrangler@4 deploy --config site/wrangler.jsonc
```
@@ -156,7 +164,7 @@ answers rather than files:
| `root/` | Copied to the **top** of `dist/` verbatim: `_headers`, which the host reads and never serves |
| `wrangler.jsonc` | The Worker: assets directory, url handling, 404, custom domain |
| `requirements.txt` | `markdown-it-py`, pinned. The only dependency |
| `fetch-fonts.py` | Downloads the self-hosted woff2 files, once |
| `fetch-fonts.py` | Refetches the committed woff2 files, or adds a face |
## A manifest entry
@@ -178,21 +186,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",
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23 -7
View File
@@ -18,20 +18,36 @@ in `site.css` ask for:
| `IBMPlexMono-Medium.woff2` | IBM Plex Mono | 500 | normal |
| `ZillaSlab-SemiBold.woff2` | Zilla Slab | 600 | normal |
## Fetching them
## Where they come from
The seven files are **committed**, so a clean checkout builds the real
typography with no network and a deploy is the same bytes from any
machine. Run the script only to refresh them or to add a face:
```bash
python3 site/fetch-fonts.py
```
The script asks Google Fonts for the CSS these faces would need, reads
the `woff2` URLs out of the reply, and writes the files here under the
names above. What it downloads is already the `latin` subset Google
serves — the site's copy of the font, not a link to Google's.
It asks Google Fonts for the CSS these faces would need, reads the
`woff2` URLs out of the reply, and writes the files here under the names
above. What it downloads is already the `latin` subset Google serves —
the site's copy of the font, not a link to Google's. That request
happens once, on the machine that refreshes a face; nothing the site
serves ever makes it.
Both families are licensed for this: IBM Plex under the SIL Open Font
License 1.1, Zilla Slab likewise. Keeping the downloaded `*.LICENSE.txt`
beside the woff2 files satisfies the licence's one obligation.
License 1.1, Zilla Slab likewise. The licence's one obligation is that
its text and copyright notice travel with the font, and committing the
woff2 files here is redistribution — so they do:
| File | Covers | From |
| --- | --- | --- |
| `IBMPlex.LICENSE.txt` | the six Plex faces | [IBM/plex](https://github.com/IBM/plex) |
| `ZillaSlab.LICENSE.txt` | `ZillaSlab-SemiBold` | [mozilla/zilla-slab](https://github.com/mozilla/zilla-slab) |
`fetch-fonts.py` does not fetch these — it asks the Google Fonts CSS
API, which serves the woff2 and nothing else. Adding a family means
adding its licence here by hand.
## If they are absent
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017, The Mozilla Foundation
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+24
View File
@@ -171,6 +171,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;
@@ -231,6 +238,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;
+11 -3
View File
@@ -6,7 +6,7 @@
A phone has room for one of those columns, so the other two are
written twice: once as the column the design draws, and once as a
<details> strip that is display:none until the breakpoint which
hides its column. The strips carry the same $sidebar and $toc, so
hides its column. The strips carry the same $$sidebar and $$toc, so
there is one source for the links and no way for the folded copy to
say something the column does not.
@@ -70,15 +70,23 @@ $sidebar
<h1>$title</h1>
<!-- "On this page", folded under the title. Shown from 1080px down,
where the gutter that normally carries it is gone. A page whose
body has no h2s gets an empty $toc, and the stylesheet drops the
strip rather than offering a summary over nothing. -->
body has no h2s gets an empty $$toc, and the stylesheet drops the
strip rather than offering a summary over nothing. It sits above
the lede on purpose: the stylesheet's lede adjacency names
.menu-contents + p for exactly this order. -->
<details class="menu menu-contents">
<summary class="menu-summary">On this page</summary>
<nav class="menu-panel">
$toc
</nav>
</details>
<!-- 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">
@@ -0,0 +1,140 @@
# 40 — A card with open questions refuses the launch, not just the agent
**Status:** Archived
**Priority:** High — the board documents this as a gate and implements it
as a suggestion; every miss spends a worktree, a branch and a full agent
run on a task nobody could answer
**Type:** Bug
`AGENTS.md` promises that "a non-empty Open-questions section makes an
agent refuse the task (`NOT READY`) rather than guess", and the task
template calls the section load-bearing. Nothing in the board enforces it.
The whole decision is a paragraph in the work prompt, judged by the model
after the launch has already built the worktree — so a card can be worked
with its questions still open, and was.
## Context
What actually decides this today:
- `manager/core/prompts/work.md:16-27` — the only implementation. It asks
the agent to read the task critically and, if it finds open questions
"that only the task's author can settle", to make no edits and reply
with `NOT READY: <reason>`. It then narrows itself: "Only questions
that change what should be built count — implementation details you can
decide yourself by reading the codebase and AGENTS.md do not."
- `manager/core/agents.py:229``start_agent()` refuses a launch from
the wrong stage (`_validate`), refuses a card someone else holds
(`_claim_for_launch`), and refuses a worktree on the wrong branch. It
never reads the task's content. By the time the prompt's check runs,
the worktree exists, the branch exists, and the run is billed.
- `manager/core/agents.py:340-345` — the `NOT READY:` marker is parsed
out of the agent's final output, and the board then walks the card back
and deletes the untouched worktree and branch. That machinery is fine;
it is the trigger that is soft.
The miss that prompted this card: **#32** ("Serve bench.12vectors.com from
a Cloudflare Worker") carried one Open question — manual `wrangler deploy`
versus a GitHub Action — and an agent started work on it anyway. That is a
defensible reading of the prompt: the entry ends with
`Recommendation: manual for v1`, and a question that carries its own
answer does not "change what should be built". So the card is two bugs
wearing one shirt — a gate that is only advisory, and an authoring
convention that lets an already-decided question sit in the section that
is supposed to stop the work.
**Affected areas:** `manager/core/agents.py` (the launch guard),
`manager/core/taskfiles.py` (reading the section),
`manager/core/prompts/work.md`, plus the authoring rules in `AGENTS.md`
and `tasks/task-template.md`.
## What to build
- **A file-carried gate before the worktree exists.** In
`start_agent()`, immediately after `_validate` and *before*
`_claim_for_launch` and any git call, read the task's
`## Open questions` section and refuse the launch when it has content.
Nothing is created, so nothing needs cleaning up, and the refusal costs
no tokens at all.
- **One definition of empty, written down once.** The section counts as
settled when it is absent, or contains only whitespace, HTML comments,
or a `None`-style line (`None.`, `- None.`). Anything else is an open
question. `taskfiles.read_task()` already returns the full `body` and
is the only module that touches task files, so the parse belongs there
— expose it as a field rather than re-reading the file in `agents.py`.
- **A refusal that ends the loop instead of starting one.** The error
names the questions it found, so the fix is obvious and immediate:
settle them in the card, or move the answered one into the body. A
refusal that just says "this task has open questions" makes the person
go hunting for what the board already read.
- **The card does not move.** Unlike a `NOT READY` return, nothing has
happened yet — no worktree, no branch, no claim. The card stays exactly
where the user put it.
- **Keep the prompt check too.** Two layers on purpose, the way the
`**PR:**` line and the actor-only rule both guard PR creation: the gate
prevents the launch, and the agent's own judgement still catches a
question phrased somewhere other than the section. Align its wording
while there — a non-empty section is a refusal on its own terms, not
something the agent re-adjudicates.
- **Fix the authoring half.** A question you have already answered is not
an open question: the decision belongs in the body and the alternative
in Notes. Say so in `tasks/task-template.md`'s guidance for the section
and in `AGENTS.md` where the section is described. Note that the
template ships `once` (per `manager/core/release-manifest`), so the
wording change reaches fresh installs only — which is fine, and worth
knowing rather than discovering.
**Out of scope** — tempting neighbours left alone:
- Review, relevance and act-PR agents. They read a card that may well
have open questions and are not doing the work; only work launches
gate.
- Any change to the `NOT READY` return path — the marker, the walk-back,
the worktree deletion — which works.
- Showing the state on the card before the click. A card that will
refuse could say so, and that is the better ergonomics, but it is a
render change riding on this card's parse. Its own card once this
lands.
- An override flag to launch anyway. The way to work a card with open
questions is to answer them.
## Acceptance
- [ ] Given an `in-progress/` card whose `## Open questions` section has
real content, when **▸ start work** fires, then the launch is
refused with a message naming the questions — and no worktree,
branch, claim or agent process is created.
- [ ] Given the same card with the section emptied to `- None.` (or
deleted), when **▸ start work** fires, then the launch proceeds
exactly as it does today.
- [ ] Given a card whose section contains only the template's HTML
comment, when it launches, then it is treated as settled — the
commented guidance is not an open question.
- [ ] **▸ take over** is gated identically: a takeover of a card with
open questions refuses for the same reason and names the same
questions.
- [ ] A relaunch on a card that already has a worktree is gated too — the
questions matter whether or not work started earlier.
- [ ] Review, PR-review, act-on-PR and **◔ still true?** launches are
unaffected on the same card.
- [ ] Edge case: a card with no `## Open questions` heading at all
launches normally — absence is not ambiguity.
- [ ] `prompts/work.md` still instructs the agent to refuse, and the
existing `NOT READY:` path still works when it does.
## Notes
The board's whole design puts gates in files rather than in intentions —
the directory is the status, the `**PR:**` line stops a second PR, an
existing worktree stops a second launch. Readiness was the one gate left
to a prompt, and prompts persuade where guards refuse.
Immediate consequence worth handling by hand, separately from this card:
**#32** is in progress with its question still open. Either settle the
deploy question in the card or walk it back — whatever this card
eventually enforces, that run should not be the thing that decides it.
**Risks** — the gate is strict by design, and a card left with a stale
"None?" note will now refuse to launch. That is the intended cost, but the
refusal message is what makes it a two-second fix instead of a mystery;
spend the care there.
@@ -1,7 +1,6 @@
# 25 — Cards order within a lane: a Rank line the board writes on drop
**Status:** To Do
**Assignee:** istos
**Status:** Backlog
**Priority:** Medium — a backlog that cannot say "this first" makes priority live in someone's head
**Type:** Feature
@@ -0,0 +1,126 @@
# 37 — Sync resolves its remote like the rest of the board, and says so when it cannot
**Status:** Backlog
**Priority:** Medium — team mode's failure is currently indistinguishable
from team mode working
**Type:** Bug
`sync.py` hardcodes `origin`, while `github.py` resolves the remote from
`BOARD_GIT_REMOTE` and falls back to the first one configured. A team-mode
board in a checkout whose remote is named anything else syncs nothing —
silently, with a healthy header. Resolve the remote in one place, and make
"there is no remote to sync with" a `sync stalled` chip like every other
condition a human has to settle.
## Context
Two facts, both in the code today:
- `manager/core/sync.py:41``REMOTE = "origin"`, a module constant
("one remote, one branch — by design"), feeding `UPSTREAM`,
`_origin_present()` (`:101`), `_fetch()` (`:132`) and `_publish()`'s
push (`:192`).
- `manager/core/github.py:40``remote()` returns `config.GIT_REMOTE`
if set, else the first name `git remote` prints, else `None`. That is
the behaviour `BOARD_GIT_REMOTE` promises in
`manager/core/.env.example:64`, and PR opening already honours it.
So one half of team mode follows the setting and the other ignores it. A
board configured `BOARD_GIT_REMOTE=upstream` opens PRs against `upstream`
and pushes its card moves nowhere.
The silence is the second half. `_converge()` returns `"no-origin"`
(`sync.py:389`) and `push_now()` returns it (`:410`), but both callers
discard the value — the beat thread calls `pull_now()` for its side
effects (`:444`) and pushes run in a daemon thread (`:431`). Nothing calls
`_note()`, so nothing reaches the ticker, and `status()` (`:71`) walks
`_NOTES`, finds nothing, and answers `state: "ok"`. The header chip only
appears when sync stops converging — and by this measure it never
started, so it never appears. A misconfigured team-mode board and a
healthy one render identically.
**Affected areas:** `manager/core/sync.py`, `manager/core/config.py` and
`manager/core/github.py` — the resolver's home and its two callers.
## What to build
- **One resolver, used by both.** `github.py` and `sync.py` are siblings
in the module map (`… events / github / drive / sync → agents`), so
sync must not import github to get it. Put the resolution in
`config.py`, which already owns `GIT_REMOTE`, and let `github.remote()`
become a thin call to it. Resolve on demand rather than at import —
`config` is imported everywhere and must not shell out at module load.
- **Sync stops holding its remote as a constant.** `REMOTE`, `UPSTREAM`
and `_origin_present()` all assume a name known before the process
starts; they become derived from the resolver at use, and the messages
that name `origin` name whatever was resolved. Rename
`_origin_present()` to match — it is not asking about `origin` any
more.
- **No remote becomes a stalled note, not a return value.** Where the
code returns `"no-origin"` today, `_note()` first, at `stalled` level:
team mode is on, this checkout has no remote to sync with, and a
person has to add one or set `BOARD_GIT_REMOTE`. The message must say
which of those two fixes it, because both are one line.
- **A named remote that does not exist stalls too**, naming it — a typo
in `BOARD_GIT_REMOTE` is more likely than no remote at all, and it
should not fall back to another remote behind the user's back.
- **It clears like the others.** When a remote appears, `_clear()` with
a recovery line, so the ticker closes the loop the way the offline
path already does.
- **Say it at startup, not on the second beat.** The condition is true
before the first converge and should be on the header from first
paint.
**Out of scope** — tempting neighbours left alone:
- `agents.py:176-194`, which hardcodes `origin` the same way when
choosing a work agent's branch point (falling back to local HEAD).
Same bug class, different consequence, and it wants its own card once
the resolver exists — see Notes.
- Supporting more than one remote, or a branch other than `main`. The
"one remote, one branch" half of that comment stands; only the name
was wrong.
- Any change to what sync does once it has a remote: the fetch, the
fast-forward, the replay and the piggyback guard are untouched.
## Acceptance
- [ ] Given `BOARD_SYNC=1` in a checkout whose only remote is named
`upstream`, when the board runs, then card moves push to
`upstream/main` and the beat pulls from it — where today nothing
happens at all.
- [ ] Given `BOARD_GIT_REMOTE=fork`, when a card enters review, then the
PR and the pushed commits go to the same remote — sync and
`github.py` never disagree about which remote is the board's.
- [ ] Given `BOARD_SYNC=1` and a checkout with no remotes, when the
board starts, then the header shows `sync stalled` and the ticker
carries one line naming both fixes (add a remote, or set
`BOARD_GIT_REMOTE`), and `status()` does not report `ok`.
- [ ] When a remote is then added, the chip clears and the ticker says
sync is converging again.
- [ ] Edge case: `BOARD_GIT_REMOTE=typo` in a checkout that *does* have
`origin` stalls naming `typo` — it does not quietly use `origin`.
- [ ] The stalled condition is narrated once, not once per beat — the
existing `_note()` dedupe covers this, and it should stay covered.
- [ ] With `BOARD_SYNC` off, none of this runs or renders: no git calls,
no chip, no ticker line.
## Notes
The bug is really about which failures this board is willing to be quiet
about. Sync's design already says every repeating condition is narrated
once and held on the chip — offline, stray commit, not-on-main, no
`main` branch. "No remote at all" was the one case that fell through to a
bare return value, and it is the one most likely to be a fresh
installation's very first state.
**Risks** — `REMOTE` and `UPSTREAM` are module-level and read in f-strings
throughout `sync.py`; making them dynamic touches most functions in the
file even though the behaviour change is small. Worth reading the whole
module before starting rather than patching call sites one at a time.
Follow-up worth a card of its own: `agents.py` decides a work agent's
branch point with a hardcoded `origin` too, and its silent fallback
("branched from local HEAD") is the same failure wearing a friendlier
face — on a repo whose remote is named otherwise, every agent branches
from a stale local `main` and the ticker sounds fine about it.
@@ -0,0 +1,125 @@
# 38 — A card being merged and cleaned up looks like it, and holds still
**Status:** Backlog
**Priority:** Medium — a daily-path illusion, with a double-fire hazard
sitting behind it
**Type:** Feature
Choosing **Merge & clean up** on a card dragged to `done/` starts a long
piece of work — park the drive, merge on GitHub, remove the worktree,
delete the branch, move the card — and the card shows none of it. It sits
in `review/` looking idle for as long as the merge takes, fully
interactive, while the board is midway through disassembling its branch.
Give the card the state it is actually in, and take its actions away until
it is out of it.
## Context
- The sheet's ship handler is `manager/core/board.html:1386-1397`: it
calls `closeSheet()`, fires one toast ("the ticker narrates each
step"), then awaits `POST /api/task/complete` and only redraws when
that returns.
- What it is waiting for is `github.complete_task()`
(`manager/core/github.py:383`): stopping this task's drive and polling
up to 20 seconds for it to die (`:404-409`), `_merge_on_origin()` or
`_merge_locally()`, `git worktree remove --force`, `git branch -D`,
then `move_task(... 'done')`. Only the last step changes anything the
card renders.
- The steps *are* narrated — each records a board event against the file
and broadcasts — so the ticker tells the story while the card
contradicts it.
- Nothing guards a second request. `/api/task/complete`
(`manager/core/httpd.py:197`) takes the call and starts work; the card
is still draggable, so a second drag re-opens the sheet, and **↻ act on
PR**, **◔ review PR**, **⛭ drive** and **↩ back** all remain armed on a
card whose branch is being deleted.
- The vocabulary for this already exists and should be reused rather than
reinvented: `--accent` plus the breathe animation means an agent is
working (`board.html:207-239` carries the `rest → armed → busy` states
for hover actions), `◌` accent chips already mark a starting agent
(`:1088`) and a running local command (`:1115`), and a working card
already carries a live activity line.
**Affected areas:** `manager/core/board.html` (card render, the complete
sheet, drag), `manager/core/state.py` (where shared registries live),
`manager/core/github.py` and `manager/core/httpd.py` (claim, release,
refuse).
## What to build
- **A card-level busy state, server-held.** `complete_task` claims the
card in a registry in `state.py` before it does anything, and releases
it in a `finally` — success, conflict or crash. `/api/state` exposes
it, so the board renders from truth rather than from what this tab
happens to have clicked.
- **Render it in the design's own terms.** The card takes the accent
border and a breathing status pill in the slot the status pill already
owns — the same language as an agent working, because that is what is
happening. No new colour: this is not an alarm and not a settled state.
- **Say which step.** The steps are already recorded as board events
against the file; show the latest one on the card the way a working
card carries its activity line, so "parking the drive", "merged
task/29-… into main" and "cleaned up: worktree and local branch
removed" land on the card and not only in the ticker.
- **Take the actions away.** While a card is claimed: no hover actions,
not draggable, and the drawer's actions for it inert. Suppressed, not
merely ignored on click — an action that looks available and does
nothing is the same lie in a different place.
- **Refuse the second request.** A `complete` for a card already claimed
returns a readable error rather than starting a second merge, and the
toast says the card is already being completed.
- **Release loudly on failure.** A merge conflict aborts cleanly today
and the card stays in `review/`; it must also come fully back to life,
with the existing error toast unchanged. A card stuck busy forever is
worse than the problem this card fixes.
**Out of scope** — tempting neighbours left alone:
- Hover actions that already run through `rest → armed → busy`
(`board.html:1191-1230`). They are fine; the sheet path is what skipped
the pattern.
- What `complete_task` actually does. Not one git command changes here.
- Cross-board liveness. Agent registries stay in each board's own
memory, and "State syncs; reactions don't" applies: a replica sees this
card unchanged until the move arrives. Making busy states travel is a
different design decision entirely.
- A progress bar or step counter. The steps are not uniform and their
count is not known up front; the latest narrated line is the honest
rendering.
## Acceptance
- [ ] Given a `review/` card with a branch, when **Merge & clean up** is
chosen, then the card takes the busy treatment immediately — before
the drive is parked — and holds it until it lands in `done/`.
- [ ] While busy, the card cannot be dragged, shows no hover actions, and
its drawer actions do nothing — verified by trying each of **↻ act
on PR**, **◔ review PR**, **⛭ drive** and **↩ back**.
- [ ] The card shows what is happening, tracking the steps the ticker
narrates rather than a single static word.
- [ ] Given a second `POST /api/task/complete` for a card already being
completed, when it arrives, then it is refused with a readable
message and no second merge is attempted.
- [ ] Given a merge conflict, when the request fails, then the card
returns to `review/` fully interactive and the failure toast is
unchanged from today.
- [ ] Edge case: the board restarted mid-completion leaves no card stuck
busy — the registry is in memory and dies with the process, and the
card renders from disk as it always did.
- [ ] Edge case: a card with no branch and no PR still moves to `done/`
with no sheet and no flash of busy — that path is untouched.
- [ ] With `BOARD_SYNC` on, a second board shows the card unchanged
during the merge and picks up the move when it syncs.
## Notes
The gap is narrow but it is on the most consequential click on the board:
the one that merges. Everything else the board does at length — agents,
drives, commands, Copilot — already wears its work on the card. This one
path narrates into the bar and leaves the card claiming nothing is
happening.
**Risks** — the claim has to be released on every exit from
`complete_task`, including the `raise ValueError` paths for a conflict and
for a repo on the wrong branch. A `finally` around the body, not a release
after each failure point.
@@ -0,0 +1,101 @@
# 39 — The browser tab says whether agents are working
**Status:** Backlog
**Priority:** Medium — the state you most want while the tab is *not* the
one you are looking at is the one the tab does not carry
**Type:** Feature
A running agent is visible on the board and nowhere else. The moment you
switch tabs — which is the normal thing to do while an agent works for
several minutes — the only thing bench tells you is `bench · bench`,
identical whether three agents are running or none. Put the answer in the
tab title, where a backgrounded window can still say it.
## Context
- `manager/core/board.html:862``renderTitle()` writes
`S.state.project + ' · ' + VIEW_TITLES[S.view]` and nothing else. Its
comment already carries the constraint this card has to respect: the
project comes first because "tab truncation eats the tail, and the tail
is the same in every bench tab". A narrow tab shows little more than
the first few characters.
- The count is already computed one function earlier. `renderChip()`
(`:810`) filters `S.state.agents` to `status === 'running'` and renders
"N agents working" with the longest elapsed time. `/api/state` has
carried `agents: agents.list_public()` since `httpd.py:35`, so no new
data is needed — this is a second consumer of a value the page already
has.
- The server renders the first-paint title (`httpd.py:57-61`) so the tab
is right before any state arrives. That stays as it is; the indicator
is a live thing and belongs to the live render.
- `renderChip()` also tracks `liveYou` — live sessions that are not
agents, i.e. you, working. That is not what this card is about: the tab
should answer "is something happening without me", not "am I here".
**Affected areas:** `manager/core/board.html``renderTitle()` and the
constant beside it. No server change.
## What to build
- A count-carrying prefix on the tab title while agents are running,
ahead of the project name — the same reasoning that put the project
first applies harder to this, since a truncated tab must still show it.
Something in the register of the board's own mono chips rather than an
emoji: the count and a mark, then the title as it is today.
- No agents running → the title is exactly what it is now, byte for byte.
A quiet board should look untouched.
- The count is the same set the header chip counts, so the tab and the
chip can never disagree.
- The indicator survives a view switch: Board, Sessions and Focus all
carry it, since it describes the board, not the view.
- Write `document.title` only when the string actually changes. `render()`
runs on every SSE frame, and a title assigned dozens of times a second
is a needless thing to do to the browser.
**Out of scope** — tempting neighbours left alone:
- The favicon. A colour or dot on the mark would survive truncation
better than any prefix, but it is a design decision about the logo task
22 and 23 settled, and it wants the design project's answer rather than
an invented one. See Notes.
- A failed run in the tab. Different signal, sharper stakes, its own card
— see Notes.
- Notifications, sound, badging APIs, flashing the title. This is a
passive indicator, not an interruption; bench's one interruption budget
is spent on the toast a failed run already fires.
- The header chip, which is correct today and stays as it is.
## Acceptance
- [ ] Given one or more running agents, when the tab is backgrounded,
then the tab title names how many — readable in a narrow tab, i.e.
not after the project name.
- [ ] Given no running agents, the title is character-identical to
today's `<project> · <view>`.
- [ ] The tab's count and the header chip's count always agree, because
both read the same filtered list.
- [ ] Switching between Board, Sessions and Focus keeps the indicator and
changes only the view word, as it does now.
- [ ] Given an agent that finishes, when its run ends, then the tab
returns to the plain title without a reload.
- [ ] Edge case: exactly one agent reads as one, not "1 agents".
- [ ] `document.title` is assigned only on change — verifiable by
watching it across a burst of SSE frames with nothing else moving.
## Notes
The one thing the tab already does well is identify *which* bench it is,
and that must survive: a second project's board is the whole reason the
project name leads. Anything added here is a prefix on a string that is
already fighting for room, so brevity is the design.
Two follow-ups this deliberately does not do, both worth their own cards:
- **The favicon.** It never truncates, so it is the strongest carrier of
this signal — but the mark came from the design project (task 23), and
a state-coloured variant should come from there too, not from a
developer picking a dot colour.
- **A run that died.** The board treats a failed run as a state a card
wears, with a toast, precisely because it must not be missed — and a
toast fired at a backgrounded tab is missed by definition. That is a
sharper version of this same gap.
@@ -0,0 +1,132 @@
# 42 — A long closing report loses its head, which is where it says what happened
**Status:** Backlog
**Priority:** High — the task file is the permanent record, and the part it
drops is the part the reader must act on
**Type:** Bug
An agent's closing report is capped by keeping the **last** 3000
characters. The prompt tells the agent to lead with the state of the work,
so a report longer than the cap loses exactly the sentence that says what
happened, and the appended section begins mid-word. It happened on the
first report long enough to hit it.
## Context
- `manager/core/agents.py:27-32``_clean_log(text, cap=3000)` strips
hook noise and returns `"\n".join(lines).strip()[-cap:]`. A tail slice,
by characters, with no line boundary and no mark that anything was cut.
- `manager/core/github.py:193``_agent_log_tail(filename, cap=1500)`
does the same thing again, tighter, for the PR body (`:150-152`). Two
modules, two different numbers, the same wrong end.
- `manager/core/prompts/work.md:45-48` says the opposite: "Lead with the
state of the work. The first sentence after any marker line states what
happened and where things stand — committed or not, tested or not,
blocked on what — before any narrative. The one fact the reader must
not miss is the headline, never a mid-paragraph aside."
`tests/test_prompt_report_contract.py` asserts that contract across all
four templates — the prompt half is tested, the capture half is not.
The live case, and the reason this card exists. Card **32**'s run wrote a
3,619-byte report; the cap kept the last 3,000. What reached
`tasks/done/32-serve-bench-12vectors-com-from-a-worker.md` starts:
```
## Work report — 2026-07-31 11:49 (Ada)
four" — they are acceptance criteria 1, 2 and 4, and they are the ones no test in this repo can reach.
```
What the 619 discarded characters said:
> Work is committed … but **nothing has been deployed** — this headless
> run had no Cloudflare credentials and no network for `npx`, so
> `wrangler deploy`, `wrangler dev` and every live-response check are
> still outstanding.
…followed by action items 1 and 2. The card was reviewed, merged and
moved to `done/` with its record claiming nothing about a deploy that
never happened. The full text survived only in
`manager/local/state/agent/logs/32-…-113412.log`.
Three surfaces inherit the same clip: the task file (`_file_report`,
`:35`), the Sessions view (`_session_report`, `:49`) and the PR body. The
act-PR, PR-review and relevance paths (`:617`, `:643`, `:675`) find their
marker first and keep everything after it — they only tail-slice when the
marker is missing, so they are less exposed but carry the same fallback.
**Affected areas:** `manager/core/agents.py`, `manager/core/github.py`,
and wherever the shared cap ends up.
## What to build
- **Keep the head.** The report's first lines are the contract; whatever
else is dropped, those survive. This is the whole fix.
- **Keep the tail too, with an explicit elision.** Both ends carry
contract-mandated content — the headline at the front, the
"review first" pointer at the end — so clip the middle and say so, in
words, on its own line. A reader must never have to infer that
something was removed.
- **Point at the full text.** The elision names the log file under
`manager/local/state/agent/logs/`, so the record says where the rest
is rather than merely ending.
- **Cut on a line boundary**, never mid-word. `four" — they are` is what
a character slice looks like in a permanent record.
- **Raise the cap and give it one home.** 3000 characters is roughly 450
words, below what the report contract asks for; these reports are the
design history this project deliberately keeps. Pick one documented
constant, used by both consumers, instead of 3000 in one module and
1500 in another.
- **One implementation.** The module map runs
`config → state → taskfiles → events / github / drive / sync → agents`,
so the helper must sit left of both callers — `github.py` cannot import
`agents.py`, and neither should reach sideways.
- **The PR body follows the task file.** Whatever the task file records,
the PR shows the same clip with the same elision, so the two never tell
different stories about the same run.
**Out of scope** — tempting neighbours left alone:
- What the agent writes. The prompt's report contract is right; it is the
capture that disagrees with it.
- Marker parsing (`NOT READY:`, `PR REVIEW:`, `ADDRESSED:`) — those sit
at the front and are helped, not touched, by keeping the head.
- Log retention, rotation, or moving logs out of `local/state/`.
- The failure-excerpt path (`:376`, `:390`), which keeps a dead run's
tail deliberately — for a crash the *end* is the story. That asymmetry
is correct and should stay.
## Acceptance
- [ ] Given a report longer than the cap, when it is appended to the task
file, then it begins with the report's own first line — never
mid-sentence.
- [ ] The clipped section states in words that it was clipped and names
the log file holding the whole thing.
- [ ] Clipping happens at line boundaries; no output line is cut in the
middle.
- [ ] Given card 32's actual log as a fixture (3,619 bytes), when it is
clipped, then the result opens with "Work is committed on…" and
retains action items 1 and 2 — the regression test for this bug.
- [ ] A report shorter than the cap is passed through byte-for-byte
unchanged, with no elision line.
- [ ] The PR body and the task file carry the same text for the same run.
- [ ] The `NOT READY:`, `PR REVIEW:` and `ADDRESSED:` markers still parse
from a clipped report.
- [ ] Edge case: a run whose entire output is one very long line still
produces something readable rather than nothing.
- [ ] A failed run's excerpt still keeps the log's *tail* — this card
does not invert that.
## Notes
Worth fixing card 32's record by hand once this lands, or sooner: its
report in `tasks/done/` is missing its headline and two action items, and
the full text is still in the log. A merged card whose permanent record
omits "nothing has been deployed" is the exact failure this bug produces.
**Risks** — a clip that keeps both ends is easy to get subtly wrong on
short inputs, where the head and tail windows overlap. Handle "the cap is
larger than the text" and "the two windows meet" before anything else;
that is where an off-by-one duplicates a paragraph into the permanent
record.
@@ -0,0 +1,143 @@
# 41 — The drawer renders a wrapped list item as one item
**Status:** Done
**PR:** https://github.com/12vectors/bench/pull/30
**Assignee:** istos
**Priority:** High — the drawer is where every card is actually read, and
it currently mangles the section reviews judge against
**Type:** Bug
Open any card with an Acceptance list and the drawer invents bullets: the
second line of a wrapped item becomes its own bullet, so "…serves the
built landing page" and "over" are two entries, and `- [ ]` checkboxes
render as a literal `[ ]`. The renderer is line-based and the task files
are hard-wrapped, so almost every list on the board comes out wrong.
## Context
`manager/core/board.html:1904``md()`, "small markdown renderer — enough
for these task files, no dependencies". It splits the source into blocks
on blank lines, then treats each **physical line** inside a block as a
unit. Task files are wrapped at ~74 columns (`tasks/task-template.md` and
every card follow it), so "enough for these task files" is exactly what it
is not.
Four defects, one root cause:
- `:1941``'<ul>' + lines.map(l => '<li>' + inline(l.replace(/^\s*[-*]\s+/, '')) + '</li>')`.
A continuation line has no marker, so the strip does nothing and the
line becomes a bullet of its own. Ordered lists have the identical bug
at `:1943`.
- **Task-list syntax is unsupported.** `- [ ] Given a request…` has its
`- ` stripped and renders the `[ ]` as text. Every Acceptance section
on the board reads as literal brackets.
- **Nesting flattens.** `lines.map` ignores indentation, so a nested
sub-list renders at the same level as its parent.
- `:1949` — paragraphs join their lines with `<br>`, so prose keeps the
author's 74-column ragged edge instead of reflowing to the drawer's
width. The blockquote branch at `:1945` does the same.
The renderer serves two surfaces: the card drawer (`:1445`, `md(t.body)`)
and the plans/reference file viewer (`:1415`, `md(f.content)`) — so
`AGENTS.md`-style documents with deeper nesting go through it too.
**Affected areas:** `manager/core/board.html`, the `md()` function and its
list/paragraph CSS.
## What to build
- **Group physical lines into logical items before rendering.** Within a
list block, a new item begins only at a marker; a following line
without one is continuation text joined to the current item with a
space. That single change fixes the phantom bullets and the ragged
paragraphs together.
- **Honour indentation.** A marker indented past the current item opens a
nested list; the nesting closes when the indent returns. Two levels
handled properly is enough for these documents — more should degrade to
flat rather than break.
- **Render task-list items as checkboxes.** `- [ ]` and `- [x]` become a
checkbox glyph plus the item text, never a literal bracket pair. They
are **not** interactive: the file is the source of truth, and the
drawer must not quietly become an editor. Colour only ever means state,
so a ticked box may read as settled (`--calm`) while an empty one stays
neutral — nothing here should read as an alarm.
- **Reflow paragraphs.** Join a paragraph's source lines with a space
rather than `<br>`, so prose wraps to the drawer instead of to the
author's editor. Same for blockquotes.
- **Keep it small and dependency-free.** `board.html` is a single
self-contained file that makes no network requests; this stays a
function in it, not a library.
- Fenced code, tables, headings and horizontal rules already work —
leave them alone, and make sure the list rewrite does not disturb the
fence state machine at `:1913-1924`, which spans blocks.
**Out of scope** — tempting neighbours left alone:
- A full CommonMark implementation. The target is the markdown these
files actually contain.
- Editing task files from the drawer, including clickable checkboxes.
- Rewrapping or reformatting task files themselves. The files are fine;
the renderer is wrong.
- The card face, the ticker, or any other surface that does not use
`md()`.
## Acceptance
- [ ] Given an Acceptance list whose items wrap across two or three
source lines, when the card opens in the drawer, then there is
exactly one bullet per item and no bullet begins mid-sentence.
- [ ] `- [ ]` and `- [x]` items render as unticked and ticked checkboxes
with their text beside them; no `[` or `]` appears as text.
- [ ] Clicking a checkbox does nothing and changes no file.
- [ ] Given a nested list, when it renders, then the child items are
indented under their parent rather than flattened beside it.
- [ ] A hard-wrapped paragraph reflows to the drawer's width — narrowing
the drawer rewraps it, and no `<br>` survives at the author's wrap
column.
- [ ] Fenced code blocks are unchanged: the ASCII directory tree in
`AGENTS.md` renders through the file viewer exactly as it does
today, line breaks intact.
- [ ] Edge case: a list item containing inline code with a hyphen, and an
item whose continuation line itself starts with a word like "and",
both stay single items.
- [ ] Edge case: a table immediately following a list still renders as a
table — card 30's wrong/right table is the live case.
- [ ] Covered by a test in the same style as the board's existing
`tests/test_*.py` checks on `board.html`.
## Notes
The comment above `md()` is the bug in miniature: it was written for these
task files, and these task files are hard-wrapped prose. A renderer that
splits on newlines was only ever going to work while every list item fit
on one line.
Worth checking after the fix: the same wrapped-line assumption may show up
in the card face's activity line and in the ticker, which also carry text
extracted from these files.
---
## Work report — 2026-07-31 15:01 (Nell)
()` in `manager/core/board.html` no longer treats a physical source line as a unit:
- **Lists group into logical items first.** A new item begins only at a marker; a line without one is continuation text joined to the item above with a space. That single change removes the phantom bullets and the ragged edge inside items together.
- **Indentation is honoured.** A marker indented past its level opens a nested list and a shallower one closes back, so children indent under their parent. Depth beyond two levels nests correctly rather than degrading. Bullets and ordered lists now share one entry point, so an `<ol>` nests under a `<ul>` properly.
- **`- [ ]` / `- [x]` render as a checkbox glyph** — a `<span class="box">` inside `<li class="tick">`, never an `<input>` and with no handler, so clicking it cannot do anything or touch the file. A ticked box takes `--calm`; an open one stays neutral. New CSS sits with the other `#drawer .dbody` rules.
- **Paragraphs and blockquotes reflow**, joining source lines with a space instead of `<br>`.
- Fences, tables, headings and rules are untouched, including the fence state machine that spans blocks. One incidental cleanup: the non-fence branches now work off the block with trailing whitespace stripped, which removes a stray trailing `<br>`/space the old code emitted on a document's last block.
**How it was verified**
The renderer is a pure function, so the tests lift `esc()` and `md()` straight out of `board.html` and run them under node, asserting on real output rather than on source shape. Node is not a bench dependency, so those checks `skipUnless` it is present (GitHub's `ubuntu-latest` runner has it); four source-level invariants in the same style as the board's other `board.html` tests always run and would catch the bug's return without node. The strongest check renders **every card in `tasks/` plus `AGENTS.md`** and asserts one `<li>` per source marker and no `<br>` anywhere — the acceptance criterion applied to the whole corpus, not a fixture. Card 41's own Acceptance section and card 30's wrong/right table are both covered as the named live cases.
**For the reviewer, in order**
1. `manager/core/board.html:1936-1973``listTree()` and `listHtml()`. The indent stack is the only non-obvious part; `NestingTests` in the new file pins its behaviour.
2. The rendering has not been eyeballed in a running browser — I am headless, so the checkbox alignment (`li.tick` uses `margin-left:-18px` against the list's `padding-left:18px`) and the `--calm` tick in both Night and Daylight are worth thirty seconds in the drawer via **⛭ drive**.
**One thing to know**
The task's closing note suspected the same wrapped-line assumption in the card face's activity line and the ticker. It does not reproduce: the only other `split('\n')` sites in `board.html` are the failed-run excerpt (`:740`) and a TodoWrite plan detail (`:1780`), both machine output where line breaks are meaningful. Nothing there needs a follow-up card.
@@ -1,73 +0,0 @@
# 35 — Make the site read on a phone
**Status:** In Progress
**Assignee:** istos
**Priority:** Medium — a public URL gets opened on phones whatever the
design was drawn at
**Type:** Feature
**Depends on:** 33, 34 — there must be pages to make responsive
The docs design is drawn at a fixed 1180px with three-column articles and
a two-column hero. None of that survives a 390px screen, and the site's
first traffic will be a link pasted into a chat and opened on a phone.
Make every built page readable down to small handsets without redrawing
the desktop design.
## Context
- Design: `Bench Docs.dc.html` — panels 1a, 1b and 1c are all fixed-width
desktop frames; the design has no small-screen state to copy, so this
card decides one within its rules.
- The board solved the same problem once already: task
`../archive/09-fit-the-board-on-small-laptops.md` is the precedent for
how far this project bends a layout before it breaks it.
- The site's tokens and templates come from task 31; this card changes
their CSS, not the build.
**Affected areas:** `site/static/site.css` and `site/templates/`.
## What to build
- **Article (1a)** — the two side columns are the first to go: fold the
on-this-page list into a collapsed strip under the title, and the
section nav into a menu the page can open. Body text keeps its
measure; nothing horizontally scrolls except code.
- **Home (1b)** — hero and terminal stack, terminal below the claim. The
six doors go two-up, then one-up.
- **Code and terminals** scroll inside their own container. The page
body must never scroll sideways, on any page, at any width.
- **Tables** — the ones sliced out of `AGENTS.md` are the real hazard.
Give them a scrolling container and a visible edge, so a reader can
tell there is more to the right.
- **Type scale** that holds: the design's 52px hero and 40px article
titles need a smaller step on narrow screens without losing the Zilla
Slab display voice.
- Tap targets on every link and button that a finger has to hit.
**Out of scope** — tempting neighbours left alone:
- A night theme for the site. The board is Night-first, the site is
Daylight-first, and reconciling them is a design question, not a
responsive one — its own card if it is wanted.
- Redesigning any desktop layout. Desktop is the design; this is what
happens below it.
- A mobile navigation product: one menu that opens and closes is enough.
## Acceptance
- [ ] At 390px, 768px and 1280px wide, every built route is readable and
the page body has no horizontal scroll.
- [ ] Given a page with a wide table from `AGENTS.md`, when it is viewed
at 390px, then the table scrolls within its own container and the
page does not.
- [ ] The section nav and on-this-page list are reachable on small
screens — collapsed is fine, absent is not.
- [ ] Edge case: with a long unbroken token in a code block (a URL, a
`curl` one-liner), nothing overflows the viewport.
- [ ] The desktop rendering is unchanged from 33 and 34 at the design's
width.
## Notes
Do this in the templates and stylesheet, with container queries or plain
media queries — nothing here justifies a script.
@@ -0,0 +1,98 @@
# 35 — Make the site read on a phone
<<<<<<<< HEAD:tasks/in-progress/35-the-site-reads-on-a-phone.md
**Status:** In Progress
========
**Status:** Review
**PR:** https://github.com/12vectors/bench/pull/31
>>>>>>>> origin/main:tasks/review/35-the-site-reads-on-a-phone.md
**Assignee:** istos
**Priority:** Medium — a public URL gets opened on phones whatever the
design was drawn at
**Type:** Feature
**Depends on:** 33, 34 — there must be pages to make responsive
The docs design is drawn at a fixed 1180px with three-column articles and
a two-column hero. None of that survives a 390px screen, and the site's
first traffic will be a link pasted into a chat and opened on a phone.
Make every built page readable down to small handsets without redrawing
the desktop design.
## Context
- Design: `Bench Docs.dc.html` — panels 1a, 1b and 1c are all fixed-width
desktop frames; the design has no small-screen state to copy, so this
card decides one within its rules.
- The board solved the same problem once already: task
`../archive/09-fit-the-board-on-small-laptops.md` is the precedent for
how far this project bends a layout before it breaks it.
- The site's tokens and templates come from task 31; this card changes
their CSS, not the build.
**Affected areas:** `site/static/site.css` and `site/templates/`.
## What to build
- **Article (1a)** — the two side columns are the first to go: fold the
on-this-page list into a collapsed strip under the title, and the
section nav into a menu the page can open. Body text keeps its
measure; nothing horizontally scrolls except code.
- **Home (1b)** — hero and terminal stack, terminal below the claim. The
six doors go two-up, then one-up.
- **Code and terminals** scroll inside their own container. The page
body must never scroll sideways, on any page, at any width.
- **Tables** — the ones sliced out of `AGENTS.md` are the real hazard.
Give them a scrolling container and a visible edge, so a reader can
tell there is more to the right.
- **Type scale** that holds: the design's 52px hero and 40px article
titles need a smaller step on narrow screens without losing the Zilla
Slab display voice.
- Tap targets on every link and button that a finger has to hit.
**Out of scope** — tempting neighbours left alone:
- A night theme for the site. The board is Night-first, the site is
Daylight-first, and reconciling them is a design question, not a
responsive one — its own card if it is wanted.
- Redesigning any desktop layout. Desktop is the design; this is what
happens below it.
- A mobile navigation product: one menu that opens and closes is enough.
## Acceptance
- [ ] At 390px, 768px and 1280px wide, every built route is readable and
the page body has no horizontal scroll.
- [ ] Given a page with a wide table from `AGENTS.md`, when it is viewed
at 390px, then the table scrolls within its own container and the
page does not.
- [ ] The section nav and on-this-page list are reachable on small
screens — collapsed is fine, absent is not.
- [ ] Edge case: with a long unbroken token in a code block (a URL, a
`curl` one-liner), nothing overflows the viewport.
- [ ] The desktop rendering is unchanged from 33 and 34 at the design's
width.
## Notes
Do this in the templates and stylesheet, with container queries or plain
media queries — nothing here justifies a script.
---
## Work report — 2026-07-31 14:58 (Piper)
contents gutter goes and its strip appears, tables become their own scrollers, and an unbreakable token in running text breaks. At 760px the sidebar goes and its menu appears, the type scale steps down (`--t-hero` 52→36, `--t-title` 40→30, and again at 480px to 31/26; `--display` stays Zilla Slab at every width), and everything a finger has to hit gets `min-height: var(--tap)` — a new 44px token. At 480px the padding tightens, the masthead and footer spacers drop so the rows wrap, body text goes 15→16px, and the hero buttons go full width.
- **`tests/test_site_responsive.py`** — new, 22 tests, four groups: every media query is a `max-width` below the design and the strips are `display:none` in the base sheet; the step that hides a column is the step that shows its strip, and the strips in the *built* pages carry the same links as the columns; a table scrolls inside itself with a `local`/`scroll` edge shadow and a long token stays inside its `pre`; and the site still ships no `.js` and no `<script>`.
- **`site/README.md`** — a "Below the design's width" section documenting the three steps and the two consequences an editor of `article.html` needs to know.
### For the reviewer, first
1. **`site/static/site.css:397`, the lede rule.** The contents strip is a sibling between the `<h1>` and the body's first paragraph even where it is `display:none`, so `.prose h1 + p` alone would silently demote the lede on every article page at desktop width. The selector now names `.menu-contents + p` too, and a test pins it. This is the one place the change could have altered the 1180px rendering.
2. **`site/static/site.css:413`, the table rule.** `display:block` is what makes a `<table>` a scroll container without a wrapper element (which would have meant changing `site/build.py`, and the card scoped this to the templates and stylesheet). It has a real cost, noted in the comment: below 1080px a table *narrower* than its column no longer stretches to fill it. No page slices a table today, so this is a rule waiting for content rather than a visible change.
### To know, not to act on
- The acceptance criteria about pixel widths (390 / 768 / 1280, no horizontal body scroll) were verified by construction and by reading, not by a browser: this environment has no headless browser, and the tests assert stylesheet and markup structure instead. Someone with `npx wrangler@4 dev --config site/wrangler.jsonc` in front of them should give the three widths a look before merge — particularly the landing page's terminal at 390px and the folded nav on `/concepts/pull-requests/`.
- `.menu-contents:not(:has(a))` hides the contents strip on a page whose body has no `<h2>`s. In a browser without `:has()` support the rule is dropped and such a page would show an empty strip; every current article page has `<h2>`s, so this is only a guard for future content.
- Out of scope and untouched, as the card asked: no night theme, no desktop redesign, no navigation product beyond the one menu.
+387
View File
@@ -0,0 +1,387 @@
"""The drawer renders a wrapped list item as one item (task 41).
`md()` in board.html used to treat every *physical* line inside a block as
a unit. Task files are hard-wrapped at ~74 columns, so the second line of
an item became its own bullet, `- [ ]` rendered as a literal bracket pair,
nested lists flattened, and paragraphs kept the author's ragged edge via
`<br>`.
board.html is a single file with inline JS and no frontend test runner, so
this suite lifts `esc()` and `md()` straight out of the page and runs them
under node the renderer is a pure function of its input, so its actual
output is what to assert on. Node is not a dependency of bench itself, so
those checks skip when it is absent; the source-level invariants at the
bottom always run and are in the same style as the board's other
`test_*.py` checks on board.html.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOARD = ROOT / "manager" / "core" / "board.html"
NODE = shutil.which("node")
HTML = BOARD.read_text(encoding="utf-8")
def lift(pattern: str, what: str) -> str:
m = re.search(pattern, HTML, re.M | re.S)
assert m, f"board.html lost {what}"
return m.group(0)
HARNESS = (
lift(r"^const esc = \(s\) =>.*?\n.*?\n", "its esc() helper")
+ lift(r"^function md\(src\) \{\n.*?\n\}\n", "its md() renderer")
+ "process.stdout.write(md(require('fs').readFileSync(0, 'utf8')));\n"
)
class RendererCase(unittest.TestCase):
"""Base: run the page's own md() over a markdown string."""
@classmethod
def setUpClass(cls):
if not NODE:
return
cls._dir = tempfile.TemporaryDirectory()
cls.js = Path(cls._dir.name) / "md.js"
cls.js.write_text(HARNESS, encoding="utf-8")
@classmethod
def tearDownClass(cls):
if NODE:
cls._dir.cleanup()
def render(self, src: str) -> str:
out = subprocess.run([NODE, str(self.js)], input=src, text=True,
capture_output=True)
self.assertEqual(out.returncode, 0, out.stderr)
return out.stdout
INNERMOST = re.compile(r"<(ul|ol)>((?:(?!<(?:ul|ol)>).)*?)</\1>", re.S)
def items(self, html: str) -> list[str]:
"""The text of each top-level <li>: nested lists dropped, markup
stripped, so a test can assert on what the reader sees."""
body = re.sub(r"</(ul|ol)>\s*$", "", re.sub(r"^\s*<(ul|ol)>", "", html))
while self.INNERMOST.search(body): # peel nested lists off
body = self.INNERMOST.sub("", body)
body = re.sub(r'<span class="box".*?</span>', "", body) # the tick glyph
return [re.sub(r"<[^>]+>", "", li).strip()
for li in re.findall(r"<li[^>]*>(.*?)</li>", body, re.S)]
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class WrappedItemsTests(RendererCase):
"""One bullet per item, however the author wrapped it."""
def test_a_wrapped_item_is_one_item(self):
"""The live bug: 'serves the built landing page' / 'over' were two
bullets because the source line broke between them."""
html = self.render(
"- Given a request for the site, when it is served, then the\n"
" worker serves the built landing page\n"
"- A second item\n")
self.assertEqual(html.count("<li"), 2)
self.assertEqual(
self.items(html),
["Given a request for the site, when it is served, then the "
"worker serves the built landing page", "A second item"])
def test_continuation_lines_join_with_a_space(self):
"""Three source lines, one item, and no word welded to its
neighbour across the join."""
items = self.items(self.render(
"- one two\n three four\n five six\n"))
self.assertEqual(items, ["one two three four five six"])
def test_a_continuation_starting_with_a_word_is_not_a_bullet(self):
"""'and' opening a wrapped line is prose, not a new item."""
items = self.items(self.render(
"- The renderer is line-based\n and the task files are hard-wrapped\n"))
self.assertEqual(items, ["The renderer is line-based "
"and the task files are hard-wrapped"])
def test_inline_code_with_a_hyphen_stays_one_item(self):
"""A hyphen inside backticks is not a marker; the item keeps its
code span and does not split."""
html = self.render(
"- The strip is `/^\\s*[-*]\\s+/` and it does\n nothing here\n")
self.assertEqual(html.count("<li"), 1)
self.assertIn("<code>", html)
def test_ordered_lists_group_the_same_way(self):
html = self.render(
"1. The board creates a git worktree on a new\n"
" branch from the newest main it can see\n"
"2. The agent works in the worktree\n")
self.assertTrue(html.startswith("<ol>"), html[:40])
self.assertEqual(len(self.items(html)), 2)
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class TaskListTests(RendererCase):
"""`- [ ]` / `- [x]` become checkboxes, and only ever glyphs."""
SRC = ("- [ ] Given an Acceptance list whose items wrap, then there\n"
" is exactly one bullet per item\n"
"- [x] Fenced code blocks are unchanged\n")
def test_no_bracket_survives_as_text(self):
html = self.render(self.SRC)
text = " ".join(self.items(html))
self.assertNotIn("[", text)
self.assertNotIn("]", text)
def test_the_item_text_survives_beside_the_box(self):
self.assertEqual(
self.items(self.render(self.SRC)),
["Given an Acceptance list whose items wrap, then there "
"is exactly one bullet per item",
"Fenced code blocks are unchanged"])
def test_ticked_and_unticked_are_distinguishable(self):
html = self.render(self.SRC)
lis = re.findall(r"<li([^>]*)>", html)
self.assertEqual(len(lis), 2)
self.assertIn('class="tick"', lis[0]) # open: neutral
self.assertIn('class="tick on"', lis[1]) # done: settled
self.assertEqual(html.count('<span class="box"'), 2)
def test_a_checkbox_is_not_interactive(self):
"""A span, never an <input> and never a handler: clicking it can
do nothing, so it cannot quietly edit the file."""
html = self.render(self.SRC + "\n- [X] upper case counts as ticked\n")
self.assertNotIn("<input", html)
self.assertNotIn("onclick", html)
self.assertNotIn("contenteditable", html)
self.assertIn('class="tick on"', re.findall(r"<li([^>]*)>", html)[-1])
def test_a_bracket_that_is_not_a_checkbox_is_left_alone(self):
items = self.items(self.render("- [see the spec](../ref.md) explains it\n"))
self.assertEqual(items, ["see the spec explains it"])
self.assertIn('href="../ref.md"', self.render(
"- [see the spec](../ref.md) explains it\n"))
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class NestingTests(RendererCase):
"""Children indent under their parent instead of flattening beside it."""
def test_a_nested_list_is_a_child_of_its_parent_item(self):
html = self.render(
"- parent one\n"
" - child a\n"
" - child b\n"
"- parent two\n")
self.assertEqual(self.items(html), ["parent one", "parent two"])
self.assertRegex(html, r"parent one<ul><li>child a</li><li>child b</li></ul></li>")
def test_the_nesting_closes_when_the_indent_returns(self):
html = self.render(
"- parent one\n"
" - child a\n"
"- parent two\n")
self.assertEqual(html.count("<ul>"), 2)
self.assertEqual(html.count("</ul>"), 2)
self.assertTrue(html.endswith("</ul>"))
def test_a_wrapped_child_is_still_one_child(self):
html = self.render(
"- parent\n"
" - the child wraps across\n"
" two source lines\n")
self.assertIn("<li>the child wraps across two source lines</li>", html)
def test_deeper_nesting_degrades_rather_than_breaks(self):
html = self.render(
"- a\n - b\n - c\n- d\n")
self.assertEqual(html.count("<ul>"), html.count("</ul>"))
for text in ("a", "b", "c", "d"):
self.assertIn(f"<li>{text}", html)
def test_a_nested_ordered_list_under_a_bullet_keeps_its_tag(self):
html = self.render("- parent\n 1. first\n 2. second\n")
self.assertIn("<ol><li>first</li><li>second</li></ol>", html)
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class ReflowTests(RendererCase):
"""Prose wraps to the drawer, not to the author's editor."""
def test_a_paragraph_has_no_hard_break(self):
html = self.render(
"The renderer is line-based and the task files are\n"
"hard-wrapped, so almost every list on the board comes\n"
"out wrong.\n")
self.assertNotIn("<br>", html)
self.assertIn("task files are hard-wrapped", html)
def test_a_blockquote_reflows_too(self):
html = self.render("&gt; a quoted line\n&gt; and its continuation\n"
.replace("&gt;", ">"))
self.assertNotIn("<br>", html)
self.assertIn("a quoted line and its continuation", html)
def test_no_br_survives_anywhere_in_the_corpus(self):
"""Every card on the board plus AGENTS.md: the author's wrap column
must not reach the browser."""
docs = sorted(ROOT.glob("tasks/*/*.md")) + [ROOT / "AGENTS.md"]
self.assertGreater(len(docs), 5, "no task files found to render")
for doc in docs:
with self.subTest(doc=doc.relative_to(ROOT).as_posix()):
self.assertNotIn("<br>", self.render(
doc.read_text(encoding="utf-8")))
def source_bullets(src: str) -> int:
"""How many logical list items a document contains, counted from the
source the way a reader counts them: markers only, fences skipped, and
only in blocks that actually open with one."""
marker = re.compile(r"^[ \t]*(?:[-*]|\d+\.)[ \t]+")
total, fence = 0, False
for block in re.split(r"\n{2,}", src):
ticks = block.count("```")
if fence or block.startswith("```"):
fence = (ticks % 2 == 0) if fence else (ticks % 2 == 1)
continue
lines = block.rstrip().split("\n")
if len(lines) >= 2 and re.match(r"^\s*\|.*\|\s*$", lines[0]) \
and re.match(r"^\s*\|[\s:|-]+\|\s*$", lines[1]):
continue # a table, not a list
if marker.match(lines[0]):
total += sum(1 for l in lines if marker.match(l))
return total
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class CorpusTests(RendererCase):
"""The whole board, not a fixture: one bullet per marker, no more."""
def docs(self) -> list[Path]:
found = sorted(ROOT.glob("tasks/*/*.md")) + [ROOT / "AGENTS.md"]
self.assertGreater(len(found), 5, "no task files found to render")
return found
def test_every_document_renders_one_bullet_per_marker(self):
"""Before the fix a hard-wrapped item produced a bullet per source
line; this is the acceptance criterion applied to every card."""
for doc in self.docs():
with self.subTest(doc=doc.relative_to(ROOT).as_posix()):
src = doc.read_text(encoding="utf-8")
self.assertEqual(self.render(src).count("<li"),
source_bullets(src))
def test_this_card_acceptance_list_is_checkboxes(self):
"""Task 41's own Acceptance section — the live case in the bug
report comes out as unticked boxes, no literal brackets."""
cards = sorted(ROOT.glob("tasks/*/41-*.md")) # whatever stage it sits in
self.assertTrue(cards, "task 41's card is missing from the board")
section = cards[0].read_text(encoding="utf-8").split("## Acceptance")[1]
html = self.render(section.split("## Notes")[0].strip())
self.assertEqual(html.count('<li class="tick">'), html.count("<li"))
self.assertGreaterEqual(html.count("<li"), 8)
items = self.items(html)
for item in items: # the marker itself is consumed
self.assertNotRegex(item, r"^\[[ xX]\]")
self.assertIn("no bullet begins mid-sentence", items[0])
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class UntouchedNeighboursTests(RendererCase):
"""Fences, tables, headings and rules kept working."""
TREE = ("```\n"
".task-manager/\n"
"├── AGENTS.md ← This file\n"
"│ ├── VERSION, board.py\n"
"└── manager/\n"
"```\n")
def test_a_fenced_block_keeps_its_line_breaks(self):
"""The ASCII directory tree in AGENTS.md goes through the file
viewer; every newline inside the fence has to survive."""
html = self.render(self.TREE)
body = re.search(r"<pre><code>(.*?)</code></pre>", html, re.S).group(1)
self.assertEqual(body.rstrip("\n").split("\n"), [
".task-manager/",
"├── AGENTS.md ← This file",
"│ ├── VERSION, board.py",
"└── manager/",
])
def test_a_fence_spanning_blank_lines_still_closes(self):
"""The fence state machine spans blocks — a blank line inside a
fence must not end it, and a bullet inside must stay literal."""
html = self.render("```\nfirst\n\n- not a bullet\n```\n\nafter\n")
self.assertEqual(html.count("<pre>"), 1)
self.assertNotIn("<li>", html)
self.assertIn("<p>after</p>", html)
def test_a_table_after_a_list_is_still_a_table(self):
"""Card 30's wrong/right table is the live case."""
html = self.render(
"- a bullet that wraps\n onto a second line\n\n"
"| Turn 1 says | bench actually |\n| --- | --- |\n"
"| `bench.toml` | `manager/local/.env` |\n")
self.assertIn("<table>", html)
self.assertIn("<th>Turn 1 says</th>", html)
self.assertIn("<td><code>bench.toml</code></td>", html)
self.assertEqual(html.count("<li"), 1)
def test_headings_and_rules_are_unchanged(self):
html = self.render("## What to build\n\n---\n\n- item\n")
self.assertIn("<h2>What to build</h2>", html)
self.assertIn("<hr>", html)
self.assertIn("<li>item</li>", html)
def test_html_in_the_source_is_still_escaped(self):
html = self.render("- an item with <script>alert(1)</script> in it\n")
self.assertNotIn("<script>", html)
self.assertIn("&lt;script&gt;", html)
class SourceInvariantTests(unittest.TestCase):
"""Always-run checks on board.html itself, for the case where node is
not installed: the shape of the fix, not its output."""
def test_md_no_longer_maps_physical_lines_to_items(self):
"""`lines.map(l => '<li>…')` is the bug; if it comes back, every
wrapped item sprouts a phantom bullet again."""
md = lift(r"^function md\(src\) \{\n.*?\n\}\n", "its md() renderer")
self.assertNotRegex(md, r"lines\.map\([^)]*<li>")
def test_paragraphs_and_blockquotes_do_not_emit_br(self):
md = lift(r"^function md\(src\) \{\n.*?\n\}\n", "its md() renderer")
self.assertNotIn("<br>", md,
"md() must reflow prose, not preserve the author's "
"wrap column")
def test_the_task_list_glyph_has_its_own_css(self):
"""Colour only ever means state: done reads as --calm, open stays
neutral, and neither reads as an alarm."""
rule = re.search(r"#drawer \.dbody li\.tick\.on \.box\{([^}]*)\}", HTML)
self.assertIsNotNone(rule, "board.html lost the ticked-box rule")
self.assertIn("var(--calm)", rule.group(1))
self.assertNotIn("var(--alarm)", HTML[HTML.index("li.tick"):
HTML.index("li.tick") + 600])
def test_the_renderer_is_still_dependency_free(self):
"""board.html makes no network requests; this stays a function in
it, not a library."""
self.assertNotRegex(HTML, r"<script[^>]+src=")
self.assertNotIn("cdn.", HTML)
if __name__ == "__main__":
unittest.main()
+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"
+378
View File
@@ -0,0 +1,378 @@
"""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)
# Both rails are written twice — the column the design draws, and the
# folded <details> strip that replaces it below the breakpoint. So these
# assert every rendering, rather than assuming there is one.
CONTENTS_BLOCK = re.compile(
r'<div class="toc">(.*?)</div>|<nav class="menu-panel">(.*?)</nav>', re.S)
def contents_lists(html):
"""The anchors of each rendering of "On this page", in order. The
sidebar's own menu-panel carries no toc-links and drops out."""
found = [TOC_LINK.findall(a or b) for a, b in CONTENTS_BLOCK.findall(html)]
return [links for links in found if links]
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):
"""The rail and its folded strip each mark the current page, so
there is more than one marker and every one of them names this
page and no other."""
for entry in self.articles():
here = SIDE_HERE.findall(self.page(entry["path"]))
self.assertTrue(here,
f'{entry["path"]} does not mark itself in the '
f"sidebar")
self.assertEqual({entry["path"]}, set(here),
f'{entry["path"]}: a sidebar rendering marks '
f"some other page as here")
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"])
headings = HEADING_ID.findall(html)
renderings = contents_lists(html)
self.assertTrue(renderings or not headings,
f'{entry["path"]}: headings but no contents list')
for links in renderings:
self.assertEqual(headings, links,
f'{entry["path"]}: a contents rendering and '
f"the 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()