Specs
Specs are typed records of what the code cannot show: why a project exists and what it is like (values), the constraints every decision obeys (rules), the decisions with their rejected alternatives (ADRs), and the conflicts, stories, ideas, references and runbooks around them. Plain markdown with YAML frontmatter, one record per file.
booping's own records, plans and retrospectives are published as a specs site beside these docs, rendered by booping specs site on every docs deploy.
Format in brief
A tier root holds one directory per type and each record is a file <type>/<NNNN>-<slug>.md: a four-digit number running within the type and a kebab-case slug. A record is referred to as <type>/<NNNN> (adr/0001); in a body, references are ordinary relative markdown links.
The full format — frontmatter keys, the body sections each type requires, marks for open questions — is the shipped guide: src/specs/README.md. Each type's shape is an mdcheck rule file beside it under src/specs/schemas/.
booping ships eight types — value, rule, adr, conflict, story, idea, reference, runbook — and a tier can override or add to them (see Tier schemas and tags), so a tier's types are whatever its effective schema set holds.
Write a record's content in any language, but keep its structural headings, bold labels, table columns and frontmatter keys the shipped English ones: one schema set serves every tier.
Tier schemas and tags
A tier root can carry two files of its own beside its type directories:
_schemas/<type>.yaml— an mdcheck rule file. A type the shipped set has gets this shape instead in this tier; a new name adds a type, whose records go in<type>/. It applies to that tier only — a global-tier schema never reaches a project. Use it for your own types and fields, not to translate headings.tags.yaml— a mappingtag: one-line description. Once it exists, every record's optionaltags:list must use its keys:areaorarea:subin kebab-case, withareaitself listed for everyarea:sub.
A tier schema must keep the frontmatter keys and values booping reads — type, status, kind, targets, brief, values, rules, supersedes, sources, review, review_evidence, created_by, and an ADR's alternatives status cell — as they are.
Tiers
Specs come in three tiers, each a tier root of its own:
| Tier | Where | Holds |
|---|---|---|
core |
shipped with the plugin, at specs/ |
booping's rules for every project |
global |
<home_dir>/_specs/ |
your values and rules for every project |
project |
the vault root, type directories beside plans/; or a repo directory you choose |
the project's own records |
At the vault root only the type directories are records; plans, retrospectives and everything else in the vault are left alone. To keep the project's records in the repo instead, set the project tier's path — booping specs init links it into the vault as <vault>/specs. A vault that still holds its records under <vault>/specs/ is moved to the root by /playbook migrate, which leaves a tier you pointed at the repo where it is.
Where each tier lives and what it is called is the core.specs config key. The setup playbook sets up the global and project tiers and asks for your global namespace; booping specs init creates either one on demand.
Namespaced refs
A reference without a namespace points into its own tier: rule/0003. A reference into another tier names that tier's namespace in front: booping:rule/0003 for core, <user>:rule/0003 for the global tier, where the namespace is the one you gave setup — your $USER when none is set. A project that departs from a rule of a tier above records that in its own ADR or conflict, citing the rule by its namespaced ref.
Injection
An accepted rule carrying targets: and brief: is injected into the playbooks, steps, agents and skills it targets, from all three tiers: each surface gets a ## Rules section listing the brief of every matching rule with a link to its record. Target forms, the section shape, the ref and link per tier and the --no-rules opt-out are in Playbooks → Rules.
Base guidance in every run
When the project has a specs tier, the project context the /playbook skill renders for every run ends with a ## Specs block: what the specs are for, one cue per record type that marks spec material in the work or in what you say, and the principle for working with them — apply them where they add quality or knowledge of past decisions, draft new material as the work produces it (values and anything weakening a rule stay drafts for you; the rest follows the run's mode), and do every read or write through a subagent running the specs playbook, keeping only its short answer. Below it come the values of the global and project tiers, one line each (value/NNNN — title: summary, global ones namespaced). It carries no rules or other records. The fixed part is capped at 18 lines, plus one line per value, by a test.
Open items
A gap between the records and the code — an unconfirmed ADR, a [clarify] mark, a check finding — is filed as an open item, a file under open-items/ in the vault that a plan can pick up and close; so is a request you file in your own words. The specs playbook files and works them; booping create, booping query, booping set and booping transition drive them directly.
Commands
Every verb but site takes --tier core|global|project, default project.
booping specs init
booping specs init [--tier global|project]
Creates the tier root and its README.md when absent. The default project tier is the vault root, so there init creates nothing and prints only the root. A project tier kept in the repo is created there and linked from <vault>/specs, so the records sit with the code and stay reachable from the vault. Safe to re-run: it changes only what is missing.
Prints one line per change, in the order made, then the absolute tier root as the last line — on a re-run with nothing to do, only that last line. For a project tier kept at specs/ in the repo:
created dir /home/me/Dev/app/specs
created file /home/me/Dev/app/specs/README.md
linked /home/me/Claude/app/specs -> /home/me/Dev/app/specs
/home/me/Dev/app/specs
The core tier ships with the plugin and is never created. An error prints specs init: {message} on stderr — the core tier asked for, a path in the way that is not a directory, or a <vault>/specs that is not a link to the tier — and nothing is written.
booping specs ls
booping specs ls [--tier TIER] [--type TYPE]... [--exclude REF]... [--titles]
Lists the tier's records, one per line: {type}/{NNNN} {status} {summary}, sorted by type and number. --type keeps that type and repeats to keep several. --exclude {type}/{NNNN} drops that record, repeats, and ignores a ref not in the tier. --titles puts the title before the summary: {type}/{NNNN} {status} {title}: {summary}.
booping specs next
booping specs next TYPE [--tier TIER]
Prints the next free ref of a type in the tier — the highest number plus one, e.g. adr/0004. Use it to name a new record.
booping specs check
booping specs check [--tier TIER]
Validates every record of the tier: its type's schema (the tier's own from _schemas/ when it has one), the directory layout and file names, unique numbers, frontmatter, rule targets, relative link targets, typed refs, sources paths, tags against the tier's tags.yaml, and each ADR's alternatives — every table row matches one ### option and exactly one option is accepted (at most one while the ADR is not accepted or superseded). A tier schema mdcheck cannot load is reported as an error on that file. Each finding prints as {path}:{line} {level} {check} {message}, followed by a count on stderr. A cross-tier ref whose namespace no configured tier answers to, or whose tier is absent on this machine, is a warning, not an error.
The check needs the mdcheck binary on PATH:
cargo install markdown-checker --version 0.1.2 --locked
booping specs site
booping specs site [--out DIR] [--root NAME=PATH ...] [--home NAME] [--title TEXT] [--site-url URL]
Renders one or several specs roots to a static HTML site you open in a browser straight from disk: no server and no network — fonts, icons and styles ship inside the output. The same input always produces a byte-identical tree.
--out DIR— the output directory, defaultspecs-sitein the working directory.--root NAME=PATH— a root to render, repeatable: a name ([A-Za-z0-9][A-Za-z0-9._-]*) and a directory holding type directories.~is expanded. Default: the tier and configured roots, below.--home NAME— the root rendered as the home registry (below); it must name one of the rendered roots, else the command exits 2. Default: the first--rootwhen--rootis given, else the project tier's root.--title TEXT— the site title, shown in the header and in every page's<title>; default the attached project's name, elseSpecs.--site-url URL— the publichttp://orhttps://URL the site is served at; default none. Given, the build also writes404.html(below).
Root selection. Given --root at least once, exactly those roots are rendered, in the order given. Otherwise the roots are the three tiers — core, global, project, each named by its namespace, the project tier by the project's name — followed by every entry of core.specs.site.roots. A tier whose directory does not exist is left out silently; a configured root whose directory does not exist is skipped with a warning. A root resolving to a directory already selected is skipped, so a tier that is also a configured root renders once. A root's name is its namespace on the site: name:type/NNNN refs resolve against it, so give a configured root the namespace its records are cited by.
To build one site over every project's specs, list them in the global config and run the command from home_dir:
# ~/.config/booping/config.yaml
home_dir: ~/Claude
core:
specs:
site:
roots:
- {name: app, path: ../Dev/app/specs}
- {name: api, path: /home/me/Dev/api/specs}
A path is absolute, ~-prefixed, or relative to home_dir.
Home registry. One root is the site's home registry — --home when given, else the first --root, else the project tier's root among the default roots: the home page, its counters and the sidebar's Types cover it alone, and every other root — core, global, configured — is a registry of its own, with its own block on the home page and its own sidebar group. A record of another registry shows its namespaced id (booping:rule/0032) wherever an id is shown — list rows, Recently updated, search results, relations, Referenced by, breadcrumbs; the home registry's records keep the bare rule/0032. Without a home registry — default roots with no project tier on disk and no --home — every root counts together on the home page and ids stay bare, except in a record page's relations, where a record of another root than the page's shows its namespace. A single root that is the home registry renders the home page with no other blocks.
Page shell. Every page has a skip link, a header with the site title (linking home) and a theme toggle, a sidebar, breadcrumbs, and a footer naming the generator — no build date, so rebuilds stay byte-identical. The theme toggle cycles auto → light → dark and remembers the choice in the browser; auto follows the system setting. Text is set in Golos Text, code in JetBrains Mono, both Cyrillic and Latin. Below 760px wide the layout drops to one column, list rows become cards, the filter bar folds into a filter sheet (see Filters), and the sidebar moves into a drawer opened from a menu button; without JavaScript the sidebar renders in the page flow instead, and every page still reads.
The sidebar, top to bottom: Overview; Awaiting review with the number of rows waiting for you; All records with the total; Types — every type that has records, across roots, with its count, each opening All records filtered to it (with a home registry: the home registry's types and counts, each opening its type page); with a home registry, one group per other registry, headed by its name, linking its landing and its type pages with counts; Tags — for each root with tags, its top-level tags that records carry, as plain links with counts, and a link to the root's tag tree (the root's name as a sub-heading when more than one root has tags); Sources — each root with its record count, linking its landing (without a home registry only); and llms.txt.
Pages.
index.html— home: the site title; the first value record's summary as the lead; when anything waits for you, one callout linking the review page (3 need you →) followed by the number of records not accepted and of open[clarify]questions, a zero count left out (3 need you → · 2 not accepted · 1 open question); a card per type with records — name, count and a one-line scenario ("why the project exists" for values) — opening All records filtered to that type; and Recently updated, the ten records with the latestupdated_at. The title, lead, callout and Recently updated sit in one reading column; the cards span the full width. With a home registry the lead, callout (counting the vault's open items too and linkingreview.html#source={home}), cards (opening the home registry's type pages) and Recently updated cover the home registry only; each other registry follows in a block headed by its name and record count, linking its landing, with its own callout and type cards.review.html— everything that needs you, as a filterable list (below).records.html— every record of every root, as a filterable list.{root}/index.html— for the home registry, a redirect toindex.html(meta refresh and a link), so old links keep working; for any other root, the root's landing, shaped like home and scoped to that root: the root's name as the title; when anything in the root waits for you, the callout with the root's counts, linking the review page filtered to the root (../review.html#source={root}); a card per type with records in the root; and Recently updated, the root's ten latest records. The root's[clarify]and[unknown]marks are on the review page and in each record's Open questions block.{root}/{type}/index.html— the type's scenario, then a filterable list of its records.{root}/tags/index.html— the root's tag tree: every tag nested under its parent (areaabovearea:sub), with its description and the number of records carrying it or any tag below it. Written only for a root that has tags.{root}/tags/{tag}.html— one tag, its:written as.(area:sub→area.sub.html): breadcrumbs through its parent tags, the description, the tags below it, then a filterable list of every record carrying the tag or one below it.{root}/{type}/{NNNN-slug}.html— one record: title, state badges, tags linking to their tag pages, summary, then labelled facts — author, created, updated, review level (with an icon: filled foraccepted, hollow below it), review evidence, sources — with dates written out in the site's language, then, as rows of the same facts list, every relation (values,rules,supersedes, …) as titled links with the target's status, plus Superseded by when a later record supersedes this one, any other frontmatter key, the body, and Referenced by — every record linking to this one, across roots — all in one reading column. Around the body:- On this page — a rail beside the text, at 1100px and wider, listing the body's
##sections with their###sections nested, then Referenced by, and highlighting the section you are reading as you scroll. When the body holds[clarify]or[unknown]marks, an Open questions block below it lists each mark — clarify first — linking to its line. The rail shows when the body has a##or###heading or any mark; narrower screens get the same blocks in a collapsed panel under the summary. §section links — every##to####heading, and Referenced by, ends with a§link to itself, shown on hover or focus (always on touch screens). Clicking it moves the address to that section and copies the full link to the clipboard,file://included; a collapsed panel holding the target opens itself.- Referenced by, grouped — the citing records under one heading per type, each followed by via: the frontmatter relations naming this record (
Rules, …) and the##sections of the citing record whose text links here. When a link points at one of this record's own sections (…/0003-x.md#decision), a By section block lists those sections with the records citing each. - ADR option cards — in an
adrwhose## Alternativesholds the shipped options table (Option, Status, Complexity, Reversibility, Verdict) the table stays as the summary, the accepted option's row marked chosen, and each###option becomes a card: status, complexity and reversibility from its table row, then the verdict and the option's own text. Accepted options come first and open, the rest collapsed in written order; below 760px the table is hidden and the cards carry it. An Alternatives section of any other shape renders as written. search.html— every search result for the query in its address,search.html?q=…(below).comments.html— the Outbox: every review comment and accepted mark you left on the site, as the text an agent gets (below).
A row in a list shows the record's ref, its title linking to the record page, its state badges and its summary.
Plans and retrospectives. When the site renders its default roots for a project with a vault and a home registry, the vault's plans and retrospectives get pages too; a vault holding neither renders exactly as without them. Plans are the files core.plans.glob claims (plans/*/index.md) plus the older single-file plans plans/*.md beside them — a directory plan wins over a file of the same slug; a plan's milestones are the files core.plans.milestones.glob claims inside its directory; retrospectives are the files core.learn_playbook.queries.candidates.glob claims (retrospectives/*.md). Other run artefacts — code reviews, sprint notes, a plan directory's other files — stay off the site.
{home}/plans/index.html— every plan, newest first by itscreateddate (else the slug's leadingYYYYMMDD): the date, the title, the status badge, and its SP and milestone count; a Status filter and a sort by date or title, with the same address syntax as the other lists (plans/index.html#status=in-progress).{home}/plans/{slug}/index.html— one plan: title, status, summary, then its kind, SP, milestone count, created, started and completed dates and its retrospective (the plan'sretro:or a retrospective naming the plan), the renderedindex.md, a Milestones table (id, title linking the milestone's page, SP, status) and Plan files linking the siblings that exist —request.md,clarifications.md,develop-defaults.md. A single-file plan has neither.{home}/plans/{slug}/{milestone}.htmland{home}/plans/{slug}/{sibling}.html— a milestone or sibling file, its breadcrumbs through the plan, linking back to it.{home}/retrospectives/index.htmland{home}/retrospectives/{slug}.html— every retrospective newest first bydate, and one page each: itsgoal_summaryas the lead, the date, the plans it names linking their pages (a plan named by its old single-file pathplans/{slug}.mdstill resolves), the body.
Relative links in a plan, milestone or retrospective body resolve like a record's: to a record page, to another plan page, or not at all. The sidebar gains a Plans group (All plans, and the plans in progress when any) and a Retrospectives group, each with counts; the home page gains Recent plans, the five newest. Search covers plan and retrospective titles, not their bodies. The review panel works on these pages as on record pages, keyed by the plan's slug ({slug}/{milestone} for a milestone or sibling, retrospectives/{slug} for a retrospective). The summary line adds N plans, N retrospectives when there are any.
A root's tags are the keys of its tags.yaml (see Tier schemas and tags), every tag its records carry, and each of those tags' parents; a tag's description comes from tags.yaml and is empty for a tag the file does not list. A tags.yaml that does not parse prints one warning and the tree is built from the tags the records carry.
Filters. A list page carries chips above the rows, grouped by facet — Type, Source (the root), Status, Needs — each chip showing how many rows on the page carry its value; a facet with only one value shows no chips, except Needs. Chips in one facet widen the list (any of them), chips in different facets narrow it (all facets). A sort menu orders the rows by number (the default), most recently updated, or title; the count below the chips says how many rows are shown. Below 760px wide the chips and sort move into a bottom sheet opened from a compact Filters row, which names how many chips are pressed and the current sort; the pressed chips show under that row as small chips that remove themselves on tap, and chips and sort in the sheet apply as you tap. The state lives in the address, so a filtered list is a link you can share or bookmark:
records.html#type=rule— the rules only;records.html#source=api— the records of rootapi;records.html#status=accepted,proposed— accepted or proposed records;records.html#flag=clarify— records with a[clarify]mark;records.html#type=adr&status=accepted&sort=updated— accepted ADRs, newest first.
Keys come in the order type, source, status, flag, then sort when it is not the page's default; several values of one key are comma-separated. A key or value the page does not know is ignored. Without JavaScript the chips are hidden and every row stays listed.
Search. Every page's header carries a search box; press / anywhere outside a text field to jump into it. Typing opens a dropdown of the first eight matches — ref, title, status — and a last line, All results →, opening search.html?q=…. Down and Up move through the list, Enter opens the highlighted result or, with none highlighted, the results page; Esc closes the list, a second Esc clears the box. Below 760px the box gives way to a search button in the header that opens it full-screen; Esc or the close button returns you to the page. search.html lists every match as list rows with their summary and a count.
Matching ignores case and treats ё as е. A query word matches a record field when a word of the field starts with the query word minus a crude ending (a word longer than seven letters loses its last three, longer than five its last two), so решени finds «Решение» and елка finds «Ёлка». Every query word must match somewhere. Results rank by where they matched — ref, then title, tags, summary, body — and then in All records order.
The data lives in two files at the site root: search-index.js (each record's ref, title, summary, status, tags and page) and search-body.js (each record's body as plain text). A page loads them only when you first use the box — the body file once you type — as plain <script> files, so search works from file:// with no server. Without JavaScript the box submits to search.html, which says that search needs JavaScript and links All records.
Review. review.html lists every record that needs you, in All records order, each row showing why with its badges — the clarify and unknown badges link straight to the first such mark in the record. A record is listed when any of these holds, and each is a Needs chip on the page:
- Not accepted —
reviewis notaccepted: no key,shownordiscussed; - Has [clarify] / Has [unknown] — the body holds such a mark;
- Open conflict — a
conflictrecord with statusopen; - Proposed ADR — an
adrwith statusproposed; - Open — an
ideawith statusopen, or an open item.
When the site renders its default roots for a project with a vault, the vault's open items with status open follow the records — the item's id and title, linking to the record it names when that record is on the site. The review page sorts by Review order by default: what the agent wrote first, then the oldest by updated_at; it also offers the other sorts. With nothing to review it says so. The sidebar's Awaiting review count and the home callout are the number of rows on this page.
Review panel. Every record, plan and retrospective page carries a panel beside the body — always visible from 1100px up, under the page outline (now a collapsible On this page at the panel's top); narrower, a bottom sheet opened by the header's comment button, which shows the page's count. Top to bottom:
- Accepted (record pages only — plans and retrospectives have no
review:) — a checkbox for the whole record; it is sent as a{ref} — accepted {YYYY-MM-DD}line with the day you ticked it, with or without comments; - Comment on the whole record — a free-form box (no quote); write as you like: what you type is saved as you type and already counts as the record's whole-record comment; Add (or Ctrl/Cmd+Enter) pins it as a card and empties the box for the next one;
- the page's comment cards, newest first. Selecting text in the body — with the mouse, by touch, or with Shift and the arrows — makes a card with the quote and the nearest heading above it, and puts the cursor in its note. Clicking a card edits its note; clicking its quote scrolls to the place and flashes it. Notes save as you type; Delete removes a card; a card left with an empty note is dropped, so a selection made only to copy text leaves nothing behind. Quoted passages are highlighted in the body; a quote no longer found there (the record changed) keeps its text with a text changed note.
Outbox. The header's Outbox button, on every page, carries the count of comments and accepted marks on the site and opens comments.html: every item grouped by record, the record with the newest item first and its items newest first, each shown as the exact text it adds to the block, with Open (the place) and Delete. Copy all copies the block below and marks nothing; Clear, after a confirmation in the page, removes every comment, accepted mark and unsent draft from the browser. There is no sent state: clear once the agent has applied the review — an item sent twice is harmless, the agent checks each against the record first.
## {ref} — {title}
{ref} — accepted {YYYY-MM-DD}
[§ {anchor text}]({anchor url})
> {quote line 1}
> {quote line 2}
{note}
[{title}]({page url})
{note on the whole record}
## {next ref} — {next title}
…
The anchor line links the section the comment sits under; a comment on the whole record, or above the record's first heading, links the record by its title. Links are absolute — the published https:// address, or a file:/// path for a site opened from disk. The block holds no UI words, so it reads the same on a site in any language. The agent that gets it edits the records and turns each accepted line into review: accepted with review_evidence: site review — {YYYY-MM-DD} — never on its own initiative.
Comments live in the browser's local storage, one store per site address, in this browser only: nothing is uploaded, nothing reaches the build, and another browser or device does not see them. Comments keep the specs-comments:v1:{site address} key; accepted marks and unsent drafts sit beside it under specs-accepted:v1: and specs-drafts:v1:, so comments saved by the earlier comment UI show up in the panel and the Outbox unchanged. With storage unavailable the panel and the Outbox button stay hidden and comments.html says comments need browser storage. Chrome and Chromium share one store across a site opened from file://; Firefox gives each file:// page its own, so there a record's panel works but the Outbox sees only items made on itself — a published site is unaffected.
State badges. Each badge carries an icon and text, never colour alone: the record's status (accepted, proposed, …), the count of [clarify] marks (2 questions for you, linking to the first mark on a record page), [unknown] when the body has one, and not accepted when review is not accepted.
Relative links and typed refs between records become links to the target's page, across roots too. A link to something that is not a record renders as plain text; a link or ref whose target is missing renders as plain text and prints a warning. The site never fails on a link — booping specs check is the validator.
Language and labels. Page text — UI strings, type names and scenarios, statuses, fact names, author names and month names — comes from the label table for core.specs.site.lang (default en; en and ru ship), which also sets <html lang>. A project whose records are written in another language sets it in its vault config — content language is not detected, so a Russian vault left on the default publishes Russian text under lang="en". A language with no table renders with English labels, keeps its <html lang> and prints one warning. core.specs.site.labels.{lang} overrides or adds labels key by key through the usual config merge, falling back to en and then to the raw id, so a project names its own types without restating the rest. A project tier with a _schemas/mechanic.yaml type, published in Russian:
# {vault}/config.yaml
core:
specs:
site:
lang: ru
labels:
ru:
types:
mechanic: {name: Механика, plural: Механики, scenario: "Механики — как устроен мир"}
A label holding counts is a list of plural forms: two (one, other) or three (the Slavic one, few, many), as in clarify: ["{n} вопрос к тебе", "{n} вопроса к тебе", "{n} вопросов к тебе"]. booping config core.specs.site.labels.en prints every key with its shipped value.
Output directory. Besides the pages, every build writes:
style.css,site.js(theme toggle and drawer),fonts.csswith the fonts embedded — so nested pages opened fromfile://render them too — and the fonts' OFL licences underfonts/;search.js,search-index.jsandsearch-body.js— the search and its data;comments.html,comments.jsandcomments-format.js— the review: the Outbox page, and the scripts behind the review panel, its storage and the export;{root}/{type}/{NNNN-slug}.md— each record's source file, byte for byte, linked from its page as the Markdown alternate;llms.txt— the site in the llms.txt shape: the title, the first value's summary, then one section per root and type with a line per record linking its.mdcopy, with its summary, status and tags;index.json— the title, the language and every record's metadata in page order: id ({root}:{ref}), type, title, summary, status, tags, author and dates, page and.mdpaths, mark counts, relations, superseded-by and backlinks as ids;404.html, only with--site-url— a not-found page whose links resolve against the URL's path, so it works at any depth the host serves it from;- the
.booping-sitemarker.
The command refuses — and writes nothing — when the out path is a file, lies inside one of the rendered roots, or is a non-empty directory without the marker. A directory carrying the marker is cleared and rewritten, so re-running into the same --out is safe.
Output. On success, stdout gets one line:
specs site: 3 roots, 42 records, 51 pages, 2 warnings -> specs-site
Warnings go to stderr, sorted by path then line:
/home/me/Dev/old/specs warning site root old not found, skipped
vault/adr/0002-ports.md:5 warning site unresolved ref booping:rule/0099
vault/adr/0002-ports.md:14 warning site unresolved link ../rule/0099-gone.md
An error prints one error: {message} line on stderr.
booping specs site-index
booping specs site-index [--projects PATH|-] [--out DIR] [--title TEXT]
Renders one index page in the site's design that links to several projects' specs sites — the page at the root of a GitLab Pages group. Reads no specs root; its language and labels come from core.specs.site.lang and core.specs.site.labels, as for booping specs site. The same input always produces a byte-identical tree.
--projects PATH|-— the project list, default-:-reads it as JSON from stdin, a path reads the file as YAML.--out DIR— the output directory, defaultspecs-site-indexin the working directory.--title TEXT— the site title in the header, the page heading, its single crumb and its<title>, defaultSpecs.
Input. A list of entries, each a mapping with exactly these keys:
name— required, a non-empty string, unique across the list.url— required, startinghttp://orhttps://.description— optional, a string or null.
Any other key, a top level that is not a list, an entry that is not a mapping, and every violation above is an error naming the entry by its 0-based position in the input (projects[3]: …); so is unreadable input — a missing file or a YAML or JSON syntax error.
Page. The same shell as a site page — skip link, header with the theme toggle, breadcrumbs, footer — without the sidebar. The heading, then one row per entry: the name linking to its URL, its description below. Rows are sorted by name, case-insensitively; input order never shows. A description's whitespace — newlines included — collapses to single spaces, so each renders on one line; a missing or null description leaves the row with the name only. An empty list renders the heading and No projects. (in the configured language) and still succeeds.
Output directory. index.html, the site's style.css, site.js, fonts.css and font licences, and the .booping-site marker, under the same rules as booping specs site: a non-empty directory without the marker is refused, one carrying it is cleared and rewritten.
Output. Piping a two-project list on stdin:
[
{"name": "api", "url": "https://{group}.{pages-domain}/api", "description": "Payments API."},
{"name": "app", "url": "https://{group}.{pages-domain}/app", "description": null}
]
$ booping specs site-index --out public < projects.json
specs site-index: 2 projects -> public
An error prints one error: {message} line on stderr and writes nothing.
Exit codes
| Code | init |
ls, next |
check |
site |
site-index |
|---|---|---|---|---|---|
| 0 | tier in place | printed | no errors (warnings may be printed) | site written (warnings may be printed) | index written |
| 1 | the tier is core or unavailable, or a path is in the way |
unknown type, or the tier is unavailable or has no directory | at least one error, or the tier is unavailable or has no directory | a malformed --root or one that is not a directory, a bad configured root entry, duplicate root names, no roots, a --site-url that is not http(s), or the out directory refused |
an unreadable or invalid project list, or the out directory refused |
| 2 | config could not be loaded | config could not be loaded | the check could not run: config not loaded, mdcheck missing or failing |
config could not be loaded | config could not be loaded |
Publishing on GitLab Pages
Each project publishes its own specs site on GitLab Pages, and one index project at the group's Pages root lists every project of the group carrying a topic, linking to each site. booping ships both CI jobs as templates a project includes by URL.
A project's site. The project keeps its specs in a repo-local vault. Its .gitlab-ci.yml includes the site template, which adds a pages job on the default branch running booping specs site --out public --site-url "$CI_PAGES_URL" — so the site gets its 404.html:
include:
- remote: https://raw.githubusercontent.com/A/claude-booping/master/src/ci/gitlab/specs-site.gitlab-ci.yml
pages:
variables:
BOOPING_REF: v1.1.1 # a release tag
BOOPING_REF defaults to master; pin it to a release tag so a booping change reaches the site only when you move the pin. BOOPING_REPO overrides the clone URL. The site is served at https://{group}.{pages-domain}/{project}.
The index. Create a project named {group}.{pages-domain} in the group — GitLab serves its Pages at the group's Pages root, https://{group}.{pages-domain}/. Its .gitlab-ci.yml includes the index template:
include:
- remote: https://raw.githubusercontent.com/A/claude-booping/master/src/ci/gitlab/specs-index.gitlab-ci.yml
Then, in that project's settings:
- a project access token with the
read_apiscope, stored as the masked CI variableSPECS_INDEX_TOKEN; - a pipeline schedule — the job runs on a schedule and on a manual pipeline run, never on push.
The job lists the group's non-archived projects carrying the topic through the GitLab API and pipes them into booping specs site-index --projects - --out public. Optional variables:
SPECS_TOPIC— the topic that marks a project for the index, defaultbooping-specs.SPECS_PAGES_BASE— optional fallback base URL ({base}/{project path}) for a project whose Pages URL the API does not return; normally each project's link is its own Pages URL fromGET /projects/:id/pages, so unique Pages domains work. A project without Pages is left out of the index.
Adding a project to the index is setting the topic on it; the next scheduled run lists it.
How the jobs get booping. Both run on the ghcr.io/astral-sh/uv:python3.12-trixie image, which carries uv, Python and git, shallow-clone booping at BOOPING_REF and run its bin/booping; the uv cache is kept between runs. Neither job needs mdcheck — only booping specs check does. A project adding its own specs check job installs it with:
cargo install markdown-checker --version 0.1.2 --locked
Limits. Projects in subgroups are not listed — only the group's own projects. Archived projects are skipped.