diff --git a/AGENTS.md b/AGENTS.md index fd577a0..0f6f3b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,14 +67,19 @@ Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE` work|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed from it) and `wire` (idempotently give the host project live-session -visibility). Headless jobs answer no permission prompts, so each intent is -granted exactly the side effects its prompt demands — commit and test for -work, push for act-pr, posting PR verdicts for review — with the project's -own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral -prefixes each adapter renders in its vendor's rule syntax. Adapters -translate their vendor's events into the board's normalized schema at the -edge — core never sees vendor payloads. The full contract, including the -event schema, lives in `core/adapters/README.md`. +visibility). + +Headless jobs answer no permission prompts, so each intent is granted +exactly the side effects its prompt demands — commit and test for work, +push for act-pr, posting PR verdicts for review — with the project's own +runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral prefixes +each adapter renders in its vendor's rule syntax. + +Adapters translate their vendor's events into the board's normalized +schema at the edge — core never sees vendor payloads. That is what keeps +the board's own code free of any one vendor: swapping adapters swaps the +binary that does the work, not the board. The full contract, including +the event schema, lives in `manager/core/adapters/README.md`. ## Drives @@ -291,12 +296,14 @@ is a review question, not a state, so the chip takes no colour. A launch that inherited the vendor default, and a session replayed from disk, wear no chip at all — the board says nothing rather than guessing. -**▸ start work** launches a headless `claude -p` on the task. It exists only -on `in-progress/` cards: moving a card to in-progress is the commitment, and -only then does work start — the server refuses launches from anywhere else. -In team mode it also refuses a card someone else holds, naming them; the -action reads **▸ take over** there, and firing it is the deliberate -reassignment. An unclaimed card claims itself on launch. +**▸ start work** launches a headless agent run on the task, through the +configured adapter. It exists only on `in-progress/` cards: moving a card +to in-progress is the commitment, and only then does work start — the +server refuses launches from anywhere else. In team mode it also refuses a +card someone else holds, naming them; the action reads **▸ take over** +there, and firing it is the deliberate reassignment. An unclaimed card +claims itself on launch. One agent per task at a time, and a work agent's +worktree must not already exist when it starts. 1. The board creates a git worktree at `.worktrees//` on a new branch `task/` from the newest main it can see: with an @@ -393,13 +400,14 @@ branch — additively, never rebasing or force-pushing — in a dedicated resolution commit, and refuses semantic ones, naming the collision for a human to settle. GitHub computes mergeability lazily, so an UNKNOWN reading keeps the chip's last state rather than flapping. -Tool chips (CI, copilot, PR, drive) -are destinations, not statuses: they live in the card's footer row, never + +The card wears that verdict in the design system's state colours: +approved → pine (`--calm`) border and an `approved` pill; changes asked → +terracotta (`--alarm`) and a `changes asked` pill; otherwise it stays the +neutral `waiting on you`. Tool chips (CI, copilot, PR, drive) are +destinations, not statuses: they live in the card's footer row, never squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌` -while in flight, with hover actions staying in the status pill's slot. The card wears it in the design -system's state colours: approved → pine (`--calm`) border and an -`approved` pill; changes asked → terracotta (`--alarm`) and a -`changes asked` pill; otherwise it stays the neutral `waiting on you`. +while in flight — with hover actions staying in the status pill's slot. Merging remains yours — the board never merges. The agent's first duty is to judge whether the task is actionable. If the @@ -438,9 +446,8 @@ clicks needs merge rights on the repo, not just push rights, and a branch without a PR is refused with a pointer to **↑ open PR** — there is nothing for origin to merge otherwise. Single-player merges locally, exactly as above. -One agent per task at a time; a work agent's worktree must not already -exist when starting. +## Stages The flow is linear: @@ -448,8 +455,6 @@ The flow is linear: backlog → to-do → in-progress → review → done ``` -## Stages - ### backlog/ Where new tasks are written and where they wait. A backlog task may be rough, incomplete, or fully specified — what it has in common with its neighbours is @@ -485,7 +490,7 @@ Supporting documents that tasks can link to — external specs, API documentatio research notes, screenshots, competitive analysis, regulatory references, etc. These don't move through the workflow; they're stable resources. Reference them from task files using relative links — two levels up from a stage directory -(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`). +(e.g. `[the payments API spec](../../reference/payments-api.md)`). ## Moving a task @@ -529,10 +534,10 @@ messaged `board: ()`, staged by pathspec so unrelated staged work is neither committed nor unstaged (hooks are skipped — this is bookkeeping, not code). Pushing is not part of it: those commits sit on your local `main` until you push it (or until `BOARD_SYNC` pushes them — -see below), which the PR guard above will tell you about if you forget. The -setting is off by default: a single-player -board neither writes nor clears the assignee and makes no commits, exactly -as before, and `tasks/` is committed by hand. The gate governs only whether +see "Syncing boards"), which the guard in "Pull requests" will tell you +about if you forget. The setting is off by default: a single-player +board neither writes nor clears the assignee and makes no commits, and +`tasks/` is committed by hand. The gate governs only whether a *move* writes the line — an **Assignee:** added to a file by hand is still read and shown on the card whether the gate is on or off. @@ -634,6 +639,22 @@ and keep the status in step with the directory the file sits in. Priority may carry a short justification after the level (e.g. `Medium — foundational for any real environment`). +Every header field, and who writes it: + +| Field | Required | Value | Written by | +| --- | --- | --- | --- | +| **Status** | yes | `Backlog` · `To Do` · `In Progress` · `Review` · `Done` (`Archived` for a card in `tasks/archive/`) | you, or the board on a move | +| **Priority** | yes | `High` · `Medium` · `Low`, optionally followed by a short justification | you | +| **Type** | no | `Discovery` · `Bug` · `Feature` · `Refactor` · `Chore` | you | +| **Assignee** | no | a name, taken from `git config user.name` | the board on a claiming move, or you by hand | +| **Depends on** | no | task numbers or external preconditions, comma-separated | you | +| **PR** | no | the pull request url | the board when it opens one | + +**Status** is the field the board holds you to: a header that disagrees +with the directory the file sits in is flagged `status drift`. +**Assignee** and **PR** it writes and reads itself. The rest are for +whoever picks the next card. + An optional **Type** line can record what kind of work the task is, when that isn't obvious from the title: diff --git a/README.md b/README.md index 4f59c4e..0b3ffd3 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,15 @@ install one-liner above depends on. ## The three-layer law Core knows about tasks, worktrees, PRs and events. It knows nothing about -any particular app (drivers do: `local/driver/start`), agent vendor -(adapters do: `core/adapters/`), or project (`local/` does). Full docs in -AGENTS.md; the adapter contract in `manager/core/adapters/README.md`. +any particular app (drivers do: `manager/local/driver/start`), agent +vendor (adapters do: `manager/core/adapters/`), or project +(`manager/local/` does). + +The consequence is what makes an update safe: `update.sh` replaces +`manager/core/` wholesale, and everything a project taught bench about +itself lives outside it. Full docs in [AGENTS.md](AGENTS.md); the adapter +contract in +[manager/core/adapters/README.md](manager/core/adapters/README.md). ## License diff --git a/site/README.md b/site/README.md index cbd78c4..a8c8d4f 100644 --- a/site/README.md +++ b/site/README.md @@ -18,6 +18,13 @@ one-liner is read out of `README.md`'s "Install into a repo" block and the version out of `manager/core/VERSION`, and the template is offered them as `$install_block` and `$version`. +An article page authors one sentence of its own — the lede under the +title — because a slice starts mid-document and a reader arriving from +the nav is owed a line saying what they are looking at. That is the whole +allowance. When a section reads badly on the web, the fix is the section: +edit `AGENTS.md` so it reads well in both places rather than forking the +prose into this directory. + ```bash python3 -m pip install -r site/requirements.txt # once python3 site/fetch-fonts.py # once, needs network @@ -150,21 +157,32 @@ answers rather than files: written to exactly that path — `/404.html` is the only one, and it exists because the host looks for that literal filename. - **`layout`** names a file in `templates/`. -- **`section`** groups the page in the nav and the sidebar. The IA is - read out of this file in this file's order — nothing is derived from - the directory layout. A `null` section keeps the page out of both, - which is how the 404 page stays off the nav. +- **`section`** groups the page in the nav and the sidebar, and puts it + on the reading order prev/next walks. The IA is read out of this file + in this file's order — nothing is derived from the directory layout. A + `null` section keeps the page out of the nav, the sidebar and the flow, + which is how the 404 page stays off all three. - **`source`** is repo-relative, or `null` for a landing page whose body is authored in its template rather than sliced. - **`from`** is the heading the slice starts at. It is matched on the heading's text; writing the `#`s (`## Stages`) pins the level too. - Headings inside fenced code blocks never match. + Headings inside fenced code blocks never match. It is also what + "Edit this page on GitHub" anchors to, so the link opens the section + rather than the top of a 700-line file. - **`to`** is optional. Without it the slice runs to the next heading of the same level or shallower. +- **`description`** is the page's meta description, and doubles as the + visible lede under the title. +- **`lede`** is optional, and only worth setting when the sentence a + reader should see differs from the one a search engine should. It is + the only prose a page may author. The `from` heading itself is dropped — the layout renders the page title — and what remains is promoted by `level - 1`, so a section's `###` -sub-headings land as the page's `

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

`s. Promotion stops at `

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

` peers rather than a second `

` on a page that already has one. ## What fails the build diff --git a/site/build.py b/site/build.py index 4977b14..97f2932 100644 --- a/site/build.py +++ b/site/build.py @@ -40,6 +40,13 @@ page emits — a door on the landing page as much as a link inside a slice — must resolve to something this build writes, or the build stops (`check_links`). +An article page authors exactly one sentence of its own: the lede under +the title (`$lede`, the manifest's `lede` or its `description`). A slice +begins mid-document, so a reader arriving from the nav is owed a line +saying what they are looking at — but that is the whole allowance, and a +manifest entry that tried to carry a body would still have nowhere to put +it. + ## What the host needs from the build Two things here exist for the way the site is served (site/wrangler.jsonc, @@ -151,13 +158,20 @@ def find_heading(marks: list, value: str, after: int = -1): def promote(text: str, by: int) -> str: """Shift every heading in a slice `by` levels shallower, so a section lifted out of a larger document keeps its internal hierarchy while - starting at

under the page's own

.""" + starting at

under the page's own

. + + Never above

. A slice that runs past the end of its own section — + "Stages" through "Moving a task", one page about one idea — carries + headings at the `from` heading's own level, and those would promote to + a second

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

instead: on the page they are peers, which + is what putting them on one page said.""" if by <= 0: return text lines = text.splitlines() for index, level, _ in list(headings(text)): found = ATX.match(lines[index]) - lines[index] = "#" * max(1, level - by) + " " + found.group(2).strip() + lines[index] = "#" * max(2, level - by) + " " + found.group(2).strip() return "\n".join(lines) @@ -281,6 +295,17 @@ def repo_facts(repo: Path) -> dict: # ── links ───────────────────────────────────────────────────────────── +def github_anchor(heading: str) -> str: + """GitHub's own anchor for a heading, so "Edit this page" lands on the + section the page was cut from rather than at the top of a 700-line + file. GitHub lowercases, drops punctuation that is not a hyphen or an + underscore, and turns spaces into hyphens — which is not quite + slugify()'s rule (that one collapses runs), so it is written out here + rather than shared.""" + text = heading.lstrip("#").strip().lower() + return re.sub(r"[^\w\- ]", "", text).replace(" ", "-") + + def rewrite_link(href: str, *, page: dict, source: str, manifest: dict, repo: Path) -> str: """A repo-relative link out of a markdown file is a dead path on the @@ -442,6 +467,43 @@ def render_sidebar(manifest: dict, current: dict) -> str: return "\n".join(out) +def flow(manifest: dict) -> list: + """The pages in reading order — the sidebar, flattened. Prev/next walks + this list, so what the arrows do and what the sidebar shows cannot + disagree. A page with no section (the landing page, the 404) is not on + the flow and gets no arrows.""" + return [page for group in sections(manifest) for page in group["pages"]] + + +def render_flow(manifest: dict, current: dict) -> str: + """The two arrows at the foot of an article. Absent neighbours keep + their slot as an empty span, so `next` stays on the right on the first + page exactly as it does on every other.""" + order = flow(manifest) + here = next((index for index, page in enumerate(order) + if page["path"] == current["path"]), None) + if here is None: + return "" + neighbours = ( + (order[here - 1] if here > 0 else None, "prev", "← previous"), + (order[here + 1] if here + 1 < len(order) else None, "next", "next →"), + ) + if not any(page for page, _, _ in neighbours): + return "" + out = ['") + return "\n".join(out) + + def render_contents(contents: list) -> str: if not contents: return "" @@ -507,12 +569,31 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, blob = config["blob_base"].rstrip("/") + "/" stamps = stamps if stamps is not None else stamp(site) facts = facts if facts is not None else repo_facts(repo) + + # "Edit this page" is a promise that the reader lands on the thing that + # is wrong. For a sliced page that is the section, not the file: an + # anchor built from the same `from` heading the slice starts at, so the + # two cannot point at different places. + source_url = config["repo_url"] + if source: + source_url = blob + source + anchor = github_anchor(page["from"]) + if anchor: + source_url += "#" + anchor + fields = { "stylesheet": stamps["stylesheet"], "icon": stamps["icon"], "title": escape(page["title"]), "description": escape(page.get("description") or config.get("description", "")), + # The design's lede. It is the one sentence a page is allowed to + # author, because a slice starts mid-document and a reader arriving + # from the nav needs to be told what they are looking at; the + # manifest's own `description` says that already, so `lede` only + # exists for the pages where the two want different words. + "lede": escape(page.get("lede") or page.get("description") + or config.get("description", "")), "site_title": escape(config["title"]), "site_tagline": escape(config.get("tagline", "")), "version": escape(facts["version"]), @@ -521,12 +602,13 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, "toc": render_contents(contents), "nav": render_nav(manifest, page), "sidebar": render_sidebar(manifest, page), + "flow": render_flow(manifest, page), "breadcrumb": render_breadcrumb(page), "section": escape(page.get("section") or ""), "repo_url": config["repo_url"], "issues_url": config.get("issues_url", config["repo_url"]), "releases_url": config.get("releases_url", config["repo_url"]), - "source_url": (blob + source) if source else config["repo_url"], + "source_url": source_url, "source_path": escape(source or ""), "canonical": config.get("base_url", "").rstrip("/") + page["path"], } diff --git a/site/pages.json b/site/pages.json index 335315a..a3ee4a5 100644 --- a/site/pages.json +++ b/site/pages.json @@ -39,7 +39,16 @@ "description": "backlog, to-do, in-progress, review, done — the directory a task file sits in is its status, and there is no other source of truth.", "source": "AGENTS.md", "from": "## Stages", - "to": "## Moving a task" + "to": "## Claiming a card" + }, + { + "path": "/concepts/task-files/", + "title": "Task files", + "layout": "article", + "section": "Concepts", + "description": "A task is a markdown file with a numbered name and a short header. Status is the only field the board enforces.", + "source": "AGENTS.md", + "from": "## Task file format" }, { "path": "/concepts/claiming-a-card/", @@ -91,6 +100,17 @@ "from": "## The three-layer law", "to": "## License" }, + { + "path": "/concepts/adapters/", + "title": "Agent adapters", + "layout": "article", + "section": "Concepts", + "description": "Headless jobs run through an adapter, so the board works with coding agents other than Claude Code — and never sees a vendor's payloads.", + "lede": "The adapter is the layer that knows a coding agent. It launches one headless job, and translates that vendor's events into the board's own schema — which is what keeps every other line of core free of any particular agent.", + "source": "AGENTS.md", + "from": "## Agent adapters", + "to": "## Drives" + }, { "path": "/404.html", "title": "Not found", diff --git a/site/static/site.css b/site/static/site.css index d4b1abe..d832bb2 100644 --- a/site/static/site.css +++ b/site/static/site.css @@ -166,6 +166,13 @@ a:hover{color:var(--text);text-decoration:underline} /* ── generated prose ── */ .page-article .prose{padding:30px 40px 44px;min-width:0} +/* The lede is the article's own sentence; everything after it is the + slice. It reads wider and quieter than body copy, as in the design. */ +.prose-lede{ + margin:0 0 26px;max-width:60ch; + font-size:var(--t-lede);line-height:1.6;color:var(--muted); + text-wrap:pretty; +} .prose h1{ font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em; margin:0 0 12px; @@ -218,6 +225,23 @@ a:hover{color:var(--text);text-decoration:underline} .prose td{padding:11px 14px;border-bottom:1px solid var(--canvas);color:var(--muted);vertical-align:top} .prose tr:last-child td{border-bottom:0} +/* Prev/next along the same order the sidebar shows. An absent neighbour + leaves a .spacer in its slot, so `next →` stays right-hand on the first + page exactly as it does on every other. */ +.flow{ + display:flex;gap:14px;margin-top:44px;padding-top:22px; + border-top:1px solid var(--border-soft); +} +.flow-link{ + display:flex;flex-direction:column;gap:5px;flex:0 1 auto;max-width:46%; + padding:12px 16px;background:var(--surface); + border:1px solid var(--border-soft);border-radius:11px;color:var(--text); +} +.flow-link:hover{border-color:var(--accent);color:var(--text);text-decoration:none} +.flow-next{margin-left:auto;text-align:right} +.flow-dir{font-size:var(--t-micro);letter-spacing:.08em;color:var(--dim)} +.flow-title{font:600 15px/1.3 var(--display)} + /* ── home layout (1b Dockside) ── */ .hero{ display:grid;grid-template-columns:1.05fr .95fr;gap:44px; diff --git a/site/templates/article.html b/site/templates/article.html index b4d17a2..38e198c 100644 --- a/site/templates/article.html +++ b/site/templates/article.html @@ -51,7 +51,13 @@ $sidebar
$breadcrumb

$title

+ +

$lede

$body +$flow