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:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+73
-11
@@ -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 (/^>/.test(block)) {
|
||||
out.push('<blockquote>' + inline(block.replace(/^>\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 (/^>/.test(text)) {
|
||||
// reflow, don't preserve the author's wrap column
|
||||
out.push('<blockquote>' + inline(text.replace(/^>\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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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("> a quoted line\n> and its continuation\n"
|
||||
.replace(">", ">"))
|
||||
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("<script>", 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()
|
||||
@@ -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,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("'", "'").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()
|
||||
Reference in New Issue
Block a user