From 5ac35478215c3264bdcea7384214cdf04aee70f1 Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 14:29:36 +0200 Subject: [PATCH 1/2] docs: fix the sections the site cuts its pages from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site's page bodies are heading slices of these two files, so a sentence that only makes sense to an agent inside an installed project becomes a sentence on bench.12vectors.com. Fixed at the source rather than forked into the site: - "The flow is linear" and its diagram sat at the end of "Pull requests", which put the whole stage flow on the PR page and left "Stages" with no opening. Moved under "## Stages", where it introduces what follows. - "Agents working the board" ended in a stray sentence about worktrees and one-agent-per-task; folded into the start-work paragraph it belongs to. - Start work no longer claims to launch `claude -p`: it launches through the configured adapter, which is the whole point of adapters. - "see below" and "the PR guard above" name their sections instead, since above and below are other pages on the site. - The tool-chips sentence had grown into the middle of the verdict it interrupted ("The card wears it in the design system's colours", with no nearby "it"). Split into two paragraphs. - "Task file format" gains a table of every header field, what it may contain and who writes it — including PR, which the board writes and nothing documented until now. - Paths are written from the repo root, the reference-directory example no longer names another project's regulator, and README's three-layer law links the two documents it points at rather than naming them. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 79 +++++++++++++++++++++++++++++++++++-------------------- README.md | 12 ++++++--- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd577a0..0f6f3b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//` on a new branch `task/` 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: ()`, 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: diff --git a/README.md b/README.md index 4f59c4e..0b3ffd3 100644 --- a/README.md +++ b/README.md @@ -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 From 7ab45f49fdced0b7261cbe4b6456ace152cf68e7 Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 14:29:49 +0200 Subject: [PATCH 2/2] site: the guides and concept pages, in the 1a Harbour layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the middle of the site. Nine routes, every body a heading slice of AGENTS.md or README.md, and the article layout given the furniture the design calls for. The manifest gains the two concepts nothing covered: /concepts/task-files/ (the header format, from AGENTS.md's own section) and /concepts/adapters/ (the adapter summary, which is the other half of the three-layer law). /concepts/stages/ now runs through "Moving a task", because the five directories and moving between them are one idea. The layout: - A lede under the title — the one sentence an article authors, taken from the manifest's `description` or an explicit `lede` where the two want different words. A slice starts mid-document; a reader arriving from the nav is owed a line saying what they are looking at. - Prev/next at the foot, walking the sidebar's own order so the arrows and the rail cannot disagree. Pages with no section (the landing page, the 404) are not on the flow. - "Edit this page on GitHub" anchors to the section the page was cut from, built from the same `from` heading the slice starts at. Two bugs the new pages found: - string.Template substitutes inside HTML comments, so a comment naming the body placeholder emitted the whole body twice and closed itself early on the first `-->` in it. - Promotion could produce a second

. A slice that deliberately runs past its own section carries headings at the `from` level, and those promoted to h1 on a page that already had one. Promotion now stops at h2, where they read as peers — which is what putting them on one page said in the first place. tests/test_site_pages.py covers the furniture on the real built site: the routes, the layout, the sidebar marking one page, the contents list being exactly the body's own h2s in order, the prev/next chain end to end, the edit link's anchor, the six landing-page doors, and a table, a fenced block and a nested list surviving the renderer. The scratch-repo helper now copies every file a slice links to, since the builder checks those exist. Co-Authored-By: Claude Opus 5 --- site/README.md | 30 ++- site/build.py | 88 ++++++++- site/pages.json | 22 ++- site/static/site.css | 24 +++ site/templates/article.html | 6 + tests/test_site_build.py | 22 ++- tests/test_site_deploy.py | 10 +- tests/test_site_pages.py | 355 ++++++++++++++++++++++++++++++++++++ 8 files changed, 537 insertions(+), 20 deletions(-) create mode 100644 tests/test_site_pages.py diff --git a/site/README.md b/site/README.md index cbd78c4..a8c8d4f 100644 --- a/site/README.md +++ b/site/README.md @@ -18,6 +18,13 @@ one-liner is read out of `README.md`'s "Install into a repo" block and the version out of `manager/core/VERSION`, and the template is offered them as `$install_block` and `$version`. +An article page authors one sentence of its own — the lede under the +title — because a slice starts mid-document and a reader arriving from +the nav is owed a line saying what they are looking at. That is the whole +allowance. When a section reads badly on the web, the fix is the section: +edit `AGENTS.md` so it reads well in both places rather than forking the +prose into this directory. + ```bash python3 -m pip install -r site/requirements.txt # once python3 site/fetch-fonts.py # once, needs network @@ -150,21 +157,32 @@ answers rather than files: written to exactly that path — `/404.html` is the only one, and it exists because the host looks for that literal filename. - **`layout`** names a file in `templates/`. -- **`section`** groups the page in the nav and the sidebar. The IA is - read out of this file in this file's order — nothing is derived from - the directory layout. A `null` section keeps the page out of both, - which is how the 404 page stays off the nav. +- **`section`** groups the page in the nav and the sidebar, and puts it + on the reading order prev/next walks. The IA is read out of this file + in this file's order — nothing is derived from the directory layout. A + `null` section keeps the page out of the nav, the sidebar and the flow, + which is how the 404 page stays off all three. - **`source`** is repo-relative, or `null` for a landing page whose body is authored in its template rather than sliced. - **`from`** is the heading the slice starts at. It is matched on the heading's text; writing the `#`s (`## Stages`) pins the level too. - Headings inside fenced code blocks never match. + Headings inside fenced code blocks never match. It is also what + "Edit this page on GitHub" anchors to, so the link opens the section + rather than the top of a 700-line file. - **`to`** is optional. Without it the slice runs to the next heading of the same level or shallower. +- **`description`** is the page's meta description, and doubles as the + visible lede under the title. +- **`lede`** is optional, and only worth setting when the sentence a + reader should see differs from the one a search engine should. It is + the only prose a page may author. The `from` heading itself is dropped — the layout renders the page title — and what remains is promoted by `level - 1`, so a section's `###` -sub-headings land as the page's `

`s. +sub-headings land as the page's `

`s. Promotion stops at `

`: a +slice that deliberately runs past its own section ("Stages" through +"Moving a task") carries headings at the `from` level, and those become +`

` peers rather than a second `

` on a page that already has one. ## What fails the build diff --git a/site/build.py b/site/build.py index 4977b14..97f2932 100644 --- a/site/build.py +++ b/site/build.py @@ -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

under the page's own

.""" + starting at

under the page's own

. + + Never above

. 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

on a page that already has one. They land beside the + section's children as

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 = ['") + 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"], } diff --git a/site/pages.json b/site/pages.json index 335315a..a3ee4a5 100644 --- a/site/pages.json +++ b/site/pages.json @@ -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", diff --git a/site/static/site.css b/site/static/site.css index d4b1abe..d832bb2 100644 --- a/site/static/site.css +++ b/site/static/site.css @@ -166,6 +166,13 @@ a:hover{color:var(--text);text-decoration:underline} /* ── generated prose ── */ .page-article .prose{padding:30px 40px 44px;min-width:0} +/* The lede is the article's own sentence; everything after it is the + slice. It reads wider and quieter than body copy, as in the design. */ +.prose-lede{ + margin:0 0 26px;max-width:60ch; + font-size:var(--t-lede);line-height:1.6;color:var(--muted); + text-wrap:pretty; +} .prose h1{ font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em; margin:0 0 12px; @@ -218,6 +225,23 @@ a:hover{color:var(--text);text-decoration:underline} .prose td{padding:11px 14px;border-bottom:1px solid var(--canvas);color:var(--muted);vertical-align:top} .prose tr:last-child td{border-bottom:0} +/* Prev/next along the same order the sidebar shows. An absent neighbour + leaves a .spacer in its slot, so `next →` stays right-hand on the first + page exactly as it does on every other. */ +.flow{ + display:flex;gap:14px;margin-top:44px;padding-top:22px; + border-top:1px solid var(--border-soft); +} +.flow-link{ + display:flex;flex-direction:column;gap:5px;flex:0 1 auto;max-width:46%; + padding:12px 16px;background:var(--surface); + border:1px solid var(--border-soft);border-radius:11px;color:var(--text); +} +.flow-link:hover{border-color:var(--accent);color:var(--text);text-decoration:none} +.flow-next{margin-left:auto;text-align:right} +.flow-dir{font-size:var(--t-micro);letter-spacing:.08em;color:var(--dim)} +.flow-title{font:600 15px/1.3 var(--display)} + /* ── home layout (1b Dockside) ── */ .hero{ display:grid;grid-template-columns:1.05fr .95fr;gap:44px; diff --git a/site/templates/article.html b/site/templates/article.html index b4d17a2..38e198c 100644 --- a/site/templates/article.html +++ b/site/templates/article.html @@ -51,7 +51,13 @@ $sidebar
$breadcrumb

$title

+ +

$lede

$body +$flow