Merge pull request #29 from 12vectors/task/34-guides-and-concepts-from-agents-md
34 — Generate the guides and concept pages from AGENTS.md
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -70,9 +70,15 @@ install one-liner above depends on.
|
||||
## The three-layer law
|
||||
|
||||
Core knows about tasks, worktrees, PRs and events. It knows nothing about
|
||||
any particular app (drivers do: `local/driver/start`), agent vendor
|
||||
(adapters do: `core/adapters/`), or project (`local/` does). Full docs in
|
||||
AGENTS.md; the adapter contract in `manager/core/adapters/README.md`.
|
||||
any particular app (drivers do: `manager/local/driver/start`), agent
|
||||
vendor (adapters do: `manager/core/adapters/`), or project
|
||||
(`manager/local/` does).
|
||||
|
||||
The consequence is what makes an update safe: `update.sh` replaces
|
||||
`manager/core/` wholesale, and everything a project taught bench about
|
||||
itself lives outside it. Full docs in [AGENTS.md](AGENTS.md); the adapter
|
||||
contract in
|
||||
[manager/core/adapters/README.md](manager/core/adapters/README.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+24
-6
@@ -18,6 +18,13 @@ one-liner is read out of `README.md`'s "Install into a repo" block and
|
||||
the version out of `manager/core/VERSION`, and the template is offered
|
||||
them as `$install_block` and `$version`.
|
||||
|
||||
An article page authors one sentence of its own — the lede under the
|
||||
title — because a slice starts mid-document and a reader arriving from
|
||||
the nav is owed a line saying what they are looking at. That is the whole
|
||||
allowance. When a section reads badly on the web, the fix is the section:
|
||||
edit `AGENTS.md` so it reads well in both places rather than forking the
|
||||
prose into this directory.
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r site/requirements.txt # once
|
||||
python3 site/fetch-fonts.py # once, needs network
|
||||
@@ -150,21 +157,32 @@ answers rather than files:
|
||||
written to exactly that path — `/404.html` is the only one, and it
|
||||
exists because the host looks for that literal filename.
|
||||
- **`layout`** names a file in `templates/`.
|
||||
- **`section`** groups the page in the nav and the sidebar. The IA is
|
||||
read out of this file in this file's order — nothing is derived from
|
||||
the directory layout. A `null` section keeps the page out of both,
|
||||
which is how the 404 page stays off the nav.
|
||||
- **`section`** groups the page in the nav and the sidebar, and puts it
|
||||
on the reading order prev/next walks. The IA is read out of this file
|
||||
in this file's order — nothing is derived from the directory layout. A
|
||||
`null` section keeps the page out of the nav, the sidebar and the flow,
|
||||
which is how the 404 page stays off all three.
|
||||
- **`source`** is repo-relative, or `null` for a landing page whose body
|
||||
is authored in its template rather than sliced.
|
||||
- **`from`** is the heading the slice starts at. It is matched on the
|
||||
heading's text; writing the `#`s (`## Stages`) pins the level too.
|
||||
Headings inside fenced code blocks never match.
|
||||
Headings inside fenced code blocks never match. It is also what
|
||||
"Edit this page on GitHub" anchors to, so the link opens the section
|
||||
rather than the top of a 700-line file.
|
||||
- **`to`** is optional. Without it the slice runs to the next heading of
|
||||
the same level or shallower.
|
||||
- **`description`** is the page's meta description, and doubles as the
|
||||
visible lede under the title.
|
||||
- **`lede`** is optional, and only worth setting when the sentence a
|
||||
reader should see differs from the one a search engine should. It is
|
||||
the only prose a page may author.
|
||||
|
||||
The `from` heading itself is dropped — the layout renders the page title
|
||||
— and what remains is promoted by `level - 1`, so a section's `###`
|
||||
sub-headings land as the page's `<h2>`s.
|
||||
sub-headings land as the page's `<h2>`s. Promotion stops at `<h2>`: a
|
||||
slice that deliberately runs past its own section ("Stages" through
|
||||
"Moving a task") carries headings at the `from` level, and those become
|
||||
`<h2>` peers rather than a second `<h1>` on a page that already has one.
|
||||
|
||||
## What fails the build
|
||||
|
||||
|
||||
+85
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,7 +51,13 @@ $sidebar
|
||||
<article class="prose">
|
||||
<div class="crumbs">$breadcrumb</div>
|
||||
<h1>$title</h1>
|
||||
<!-- The one sentence an article authors. Everything below it is the
|
||||
body placeholder: a heading slice of the file named in the
|
||||
gutter. (Placeholders substitute inside comments too, so this
|
||||
one cannot spell that name out.) -->
|
||||
<p class="prose-lede">$lede</p>
|
||||
$body
|
||||
$flow
|
||||
</article>
|
||||
|
||||
<aside class="gutter">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""The guides and concept pages: the middle of the site, and the half of
|
||||
the promise tests/test_site_build.py does not cover.
|
||||
|
||||
That file is about the generator — a renamed heading stops the build, a
|
||||
dead link stops the build. This one is about what a reader actually gets
|
||||
once it has run: the 1a Harbour furniture around each slice (sidebar,
|
||||
on-this-page, prev/next, "Edit this page"), and the three markdown
|
||||
constructs the sources really contain rendering as themselves rather than
|
||||
as escaped text.
|
||||
|
||||
The rule underneath all of it: no page body is authored twice. A page
|
||||
authors its title and one lede sentence; everything else on it was cut
|
||||
out of AGENTS.md or README.md by site/pages.json.
|
||||
|
||||
python3 -m unittest discover -s tests
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests.test_site_build import (BUILDER, HAS_MARKDOWN_IT, REPO, SITE,
|
||||
ScratchCase, needs_renderer, run_build)
|
||||
|
||||
# The two the layout owes a reader at the foot of every article.
|
||||
FLOW_LINK = re.compile(
|
||||
r'class="flow-link flow-(prev|next)" href="([^"]+)"')
|
||||
HEADING_ID = re.compile(r'<h2 id="([^"]+)"')
|
||||
TOC_LINK = re.compile(r'class="toc-link" href="#([^"]+)"')
|
||||
SIDE_HERE = re.compile(r'class="side-link side-here" href="([^"]+)"')
|
||||
GUTTER_LINK = re.compile(r'class="gutter-link" href="([^"]+)"')
|
||||
DOOR = re.compile(r'class="door[^"]*" href="([^"]+)"')
|
||||
LEDE = re.compile(r'<p class="prose-lede">(.*?)</p>', re.S)
|
||||
|
||||
|
||||
class BuiltSite(unittest.TestCase):
|
||||
"""The real manifest, built once into a scratch directory."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not HAS_MARKDOWN_IT:
|
||||
raise unittest.SkipTest("markdown-it-py is not installed")
|
||||
cls.out = Path(tempfile.mkdtemp(prefix="bench-pages-")).resolve()
|
||||
cls.result = run_build(REPO, cls.out)
|
||||
if cls.result.returncode != 0: # not assert: must survive python -O
|
||||
raise RuntimeError(
|
||||
f"site/build.py failed:\n{cls.result.stdout}"
|
||||
f"{cls.result.stderr}")
|
||||
cls.manifest = json.loads(
|
||||
(SITE / "pages.json").read_text(encoding="utf-8"))
|
||||
cls.flow = BUILDER.flow(cls.manifest)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if hasattr(cls, "out"):
|
||||
shutil.rmtree(cls.out, ignore_errors=True)
|
||||
|
||||
def page(self, route: str) -> str:
|
||||
return BUILDER.target_for(self.out, route).read_text("utf-8")
|
||||
|
||||
def articles(self) -> list:
|
||||
"""Every entry on the flow — the guides and the concepts, which
|
||||
are exactly the pages this task built."""
|
||||
return list(self.flow)
|
||||
|
||||
|
||||
class EveryRouteRendersInTheArticleLayout(BuiltSite):
|
||||
"""Acceptance: the routes render in 1a, and every body on them is a
|
||||
slice rather than something a person typed into the site."""
|
||||
|
||||
def test_the_seven_concepts_and_the_install_guide_are_all_there(self):
|
||||
"""Named one by one rather than counted: a route quietly dropped
|
||||
from the manifest is exactly the failure this catches."""
|
||||
routes = {page["path"] for page in self.articles()}
|
||||
for route in ("/guides/install/",
|
||||
"/concepts/stages/",
|
||||
"/concepts/task-files/",
|
||||
"/concepts/claiming-a-card/",
|
||||
"/concepts/agents-on-the-board/",
|
||||
"/concepts/pull-requests/",
|
||||
"/concepts/team-mode/",
|
||||
"/concepts/three-layer-law/",
|
||||
"/concepts/adapters/"):
|
||||
self.assertIn(route, routes)
|
||||
self.assertTrue(BUILDER.target_for(self.out, route).is_file(),
|
||||
f"{route} produced no page")
|
||||
|
||||
def test_each_one_is_the_three_column_layout(self):
|
||||
for entry in self.articles():
|
||||
html = self.page(entry["path"])
|
||||
self.assertEqual("article", entry["layout"], entry["path"])
|
||||
for furniture in ('class="page-article"', 'class="side"',
|
||||
'class="prose"', 'class="gutter"',
|
||||
'class="crumbs"'):
|
||||
self.assertIn(furniture, html,
|
||||
f'{entry["path"]} is missing {furniture}')
|
||||
|
||||
def test_no_body_is_authored_twice(self):
|
||||
"""Every article names a source file and a heading to cut from.
|
||||
The lede is the single exception, and it is one sentence in the
|
||||
manifest — not a body."""
|
||||
for entry in self.articles():
|
||||
self.assertTrue(entry.get("source"),
|
||||
f'{entry["path"]} has no source')
|
||||
self.assertTrue(entry.get("from"),
|
||||
f'{entry["path"]} has no from heading')
|
||||
|
||||
def test_the_lede_is_present_and_is_the_manifests_own_sentence(self):
|
||||
for entry in self.articles():
|
||||
found = LEDE.search(self.page(entry["path"]))
|
||||
self.assertIsNotNone(found, f'{entry["path"]} has no lede')
|
||||
wanted = entry.get("lede") or entry["description"]
|
||||
self.assertEqual(wanted.strip(),
|
||||
found.group(1).replace("'", "'").strip())
|
||||
|
||||
|
||||
class TheSidebarAndTheContentsFollowThePage(BuiltSite):
|
||||
"""The left rail says where you are in the site; the right rail says
|
||||
where you are in the page. Neither is authored: one is the manifest,
|
||||
the other is the promoted slice's own h2s."""
|
||||
|
||||
def test_the_sidebar_marks_exactly_the_page_you_are_on(self):
|
||||
for entry in self.articles():
|
||||
here = SIDE_HERE.findall(self.page(entry["path"]))
|
||||
self.assertEqual([entry["path"]], here,
|
||||
f'{entry["path"]} does not mark itself in the '
|
||||
f"sidebar")
|
||||
|
||||
def test_the_sidebar_lists_every_other_page_too(self):
|
||||
html = self.page("/concepts/stages/")
|
||||
for entry in self.articles():
|
||||
self.assertIn(f'href="{entry["path"]}"', html,
|
||||
f'the sidebar has no link to {entry["path"]}')
|
||||
|
||||
def test_the_header_nav_marks_the_section(self):
|
||||
self.assertIn('class="nav-link nav-here"',
|
||||
self.page("/concepts/stages/"))
|
||||
|
||||
def test_on_this_page_is_the_bodys_own_h2s_in_order(self):
|
||||
"""Not a subset and not a superset: the same anchors, the same
|
||||
order. A slice that grows a sub-heading grows a contents entry
|
||||
with nobody editing the site."""
|
||||
for entry in self.articles():
|
||||
html = self.page(entry["path"])
|
||||
self.assertEqual(HEADING_ID.findall(html), TOC_LINK.findall(html),
|
||||
f'{entry["path"]}: the contents list and the '
|
||||
f"headings disagree")
|
||||
|
||||
def test_a_page_with_sub_headings_really_has_a_contents_list(self):
|
||||
"""Guards the test above against passing on two empty lists."""
|
||||
html = self.page("/concepts/stages/")
|
||||
self.assertIn("On this page", html)
|
||||
self.assertIn('href="#backlog"', html)
|
||||
self.assertIn('href="#moving-a-task"', html)
|
||||
|
||||
|
||||
class PrevAndNextWalkTheFlow(BuiltSite):
|
||||
"""The arrows follow the sidebar's order, because a reader who used
|
||||
one and then the other must not be sent somewhere else."""
|
||||
|
||||
def links(self, route: str) -> dict:
|
||||
return dict((direction, target) for direction, target
|
||||
in FLOW_LINK.findall(self.page(route)))
|
||||
|
||||
def test_each_page_points_at_its_neighbours(self):
|
||||
order = [entry["path"] for entry in self.flow]
|
||||
for index, route in enumerate(order):
|
||||
found = self.links(route)
|
||||
self.assertEqual(order[index - 1] if index else None,
|
||||
found.get("prev"), f"{route}: wrong previous")
|
||||
self.assertEqual(order[index + 1] if index + 1 < len(order)
|
||||
else None, found.get("next"),
|
||||
f"{route}: wrong next")
|
||||
|
||||
def test_the_ends_of_the_flow_have_one_arrow_each(self):
|
||||
first, last = self.flow[0]["path"], self.flow[-1]["path"]
|
||||
self.assertNotIn("prev", self.links(first))
|
||||
self.assertIn("next", self.links(first))
|
||||
self.assertIn("prev", self.links(last))
|
||||
self.assertNotIn("next", self.links(last))
|
||||
|
||||
def test_an_absent_neighbour_keeps_its_slot(self):
|
||||
"""`next →` sits on the right on the first page as on every
|
||||
other, which is a spacer in the markup rather than a rule in the
|
||||
stylesheet."""
|
||||
html = self.page(self.flow[0]["path"])
|
||||
flow = html[html.index('<nav class="flow">'):]
|
||||
self.assertLess(flow.index('<span class="spacer">'),
|
||||
flow.index("flow-link"))
|
||||
|
||||
def test_the_landing_page_and_the_404_are_not_on_the_flow(self):
|
||||
"""They have no section, so they are not steps in a reading
|
||||
order — and an article that linked "previous: not found" would be
|
||||
a strange thing to ship."""
|
||||
off = [page["path"] for page in self.manifest["pages"]
|
||||
if not page.get("section")]
|
||||
self.assertEqual({"/", "/404.html"}, set(off))
|
||||
for route in off:
|
||||
self.assertNotIn('<nav class="flow">', self.page(route))
|
||||
|
||||
|
||||
class EditThisPageOpensTheSection(BuiltSite):
|
||||
"""A reader who spots a mistake has to land on the file that is
|
||||
actually wrong — and, on a 700-line brief, at the section that is."""
|
||||
|
||||
def test_it_names_the_source_file_and_its_section(self):
|
||||
blob = self.manifest["site"]["blob_base"].rstrip("/") + "/"
|
||||
for entry in self.articles():
|
||||
wanted = (blob + entry["source"] + "#"
|
||||
+ BUILDER.github_anchor(entry["from"]))
|
||||
self.assertIn(f'href="{wanted}"', self.page(entry["path"]),
|
||||
f'{entry["path"]}: "Edit this page" does not open '
|
||||
f'{entry["source"]} at {entry["from"]}')
|
||||
|
||||
def test_the_anchor_is_the_one_github_gives_that_heading(self):
|
||||
"""Spot-checked against the real headings rather than only
|
||||
against the function that made them."""
|
||||
self.assertEqual("claiming-a-card",
|
||||
BUILDER.github_anchor("## Claiming a card"))
|
||||
self.assertEqual("the-three-layer-law",
|
||||
BUILDER.github_anchor("## The three-layer law"))
|
||||
self.assertEqual("install-into-a-repo",
|
||||
BUILDER.github_anchor("## Install into a repo"))
|
||||
self.assertEqual("state-syncs-reactions-dont",
|
||||
BUILDER.github_anchor("### State syncs; reactions "
|
||||
"don't"))
|
||||
|
||||
def test_the_gutter_also_offers_the_issue_tracker(self):
|
||||
links = GUTTER_LINK.findall(self.page("/concepts/stages/"))
|
||||
self.assertIn(self.manifest["site"]["issues_url"], links)
|
||||
|
||||
def test_the_authored_pages_point_at_the_repository_instead(self):
|
||||
"""The landing page is not a slice, so there is no section to
|
||||
send anyone to."""
|
||||
for entry in self.manifest["pages"]:
|
||||
if entry.get("source"):
|
||||
continue
|
||||
self.assertNotIn("#", self.page(entry["path"]).split(
|
||||
'class="gutter-link" href="')[-1].split('"')[0])
|
||||
|
||||
|
||||
class TheDoorsOpenOntoArticles(BuiltSite):
|
||||
"""Task 33 put six doors on the landing page. This is the other end of
|
||||
them."""
|
||||
|
||||
def test_every_door_lands_on_a_page_in_the_flow(self):
|
||||
routes = {entry["path"] for entry in self.flow}
|
||||
doors = DOOR.findall(self.page("/"))
|
||||
self.assertEqual(6, len(doors))
|
||||
for door in doors:
|
||||
self.assertIn(door, routes,
|
||||
f"the door to {door} opens onto nothing")
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class MarkdownComesOutAsMarkup(BuiltSite):
|
||||
"""The edge case: a table, a fenced code block and a nested list have
|
||||
to render as themselves. The first two are in the repo's own slices —
|
||||
the header-field table in "Task file format", the command blocks in
|
||||
the install guide and the stage diagram in "Stages". A nested list is
|
||||
not, so ARenderedSliceKeepsItsShape below builds one on purpose rather
|
||||
than pretending this suite covers it."""
|
||||
|
||||
def test_a_table_renders_as_a_table(self):
|
||||
html = self.page("/concepts/task-files/")
|
||||
self.assertIn("<table>", html)
|
||||
self.assertIn("<th>Field</th>", html)
|
||||
self.assertIn("<td><strong>Status</strong></td>", html)
|
||||
self.assertNotIn("| Field |", html)
|
||||
|
||||
def test_a_fenced_block_renders_as_a_code_block(self):
|
||||
install = self.page("/guides/install/")
|
||||
self.assertIn("<pre><code", install)
|
||||
self.assertIn("mkdir .task-manager", install)
|
||||
self.assertIn("backlog → to-do → in-progress → review → done",
|
||||
self.page("/concepts/stages/"))
|
||||
|
||||
def test_a_fenced_heading_is_not_mistaken_for_a_heading(self):
|
||||
""""Task file format" fences a task file starting `# Task title`.
|
||||
It has to arrive as code, and it must not have sliced the page."""
|
||||
html = self.page("/concepts/task-files/")
|
||||
self.assertIn("# Task title", html)
|
||||
self.assertNotIn("<h1>Task title</h1>", html)
|
||||
self.assertIn("**Depends on:** 03, 05", html)
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class ARenderedSliceKeepsItsShape(ScratchCase):
|
||||
"""All three constructs in one slice, so the renderer is tested on the
|
||||
shapes rather than on the sections that happen to have them today."""
|
||||
|
||||
BODY = """
|
||||
| Stage | What it means |
|
||||
| --- | --- |
|
||||
| `review/` | built, not yet trusted |
|
||||
|
||||
```bash
|
||||
./.task-manager/start.sh
|
||||
```
|
||||
|
||||
- the board
|
||||
- narrates moves
|
||||
- opens PRs
|
||||
- and never merges
|
||||
"""
|
||||
|
||||
def test_a_table_a_fence_and_a_nested_list_all_survive(self):
|
||||
(self.repo.root / "SOURCE.md").write_text(
|
||||
f"# Doc\n\n## Section\n{self.BODY}\n", encoding="utf-8")
|
||||
self.repo.pages({
|
||||
"path": "/shapes/", "title": "Shapes", "layout": "article",
|
||||
"section": "Concepts", "description": "one of each.",
|
||||
"source": "SOURCE.md", "from": "## Section",
|
||||
})
|
||||
result = self.repo.build()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
|
||||
html = (self.repo.out / "shapes" / "index.html").read_text("utf-8")
|
||||
self.assertIn("<th>Stage</th>", html)
|
||||
self.assertIn("<code>review/</code>", html)
|
||||
self.assertIn("<pre><code", html)
|
||||
self.assertIn("start.sh", html)
|
||||
# The nested list: a <ul> inside an <li>, not two flat lists.
|
||||
self.assertRegex(html, r"<li>the board\s*<ul>")
|
||||
self.assertIn("<li>opens PRs</li>", html)
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class DriftOnARealConceptPage(ScratchCase):
|
||||
"""Inherited from task 31 and worth asserting on a page that ships:
|
||||
a section renamed in AGENTS.md stops the build, naming the route."""
|
||||
|
||||
def test_renaming_task_file_format_names_its_route(self):
|
||||
self.repo.edit("AGENTS.md", "## Task file format",
|
||||
"## The task file")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode,
|
||||
"a renamed section built cleanly")
|
||||
self.assertIn("/concepts/task-files/", result.stderr)
|
||||
self.assertIn("## Task file format", result.stderr)
|
||||
self.assertFalse(self.repo.out.exists(),
|
||||
"a failed build wrote pages anyway")
|
||||
|
||||
def test_renaming_agent_adapters_names_its_route(self):
|
||||
self.repo.edit("AGENTS.md", "## Agent adapters", "## Adapters")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("/concepts/adapters/", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user