# Authoring Round

> Run one vocab.design authoring round, up to 6 new terms per category (54 at full width, fewer as categories empty), from roster selection through briefs, parallel author agents, gates, e2e, and commit. Use when asked to author a round/batch of terms, "do another round of 6 per category", or to continue filling the candidate pool.

- Skill: `gkurt/authoring-round` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add gkurt/authoring-round`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gkurt/authoring-round/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: gkurt (https://skillmd.com/u/gkurt)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/gkurt/authoring-round

---


# Authoring round: 6 terms per category

One round takes the site up to 54 terms forward: roster from the pool, briefs with
demo hints, one parallel author agent per category plus one verify agent, then
e2e centrally, then a single commit. Eleven rounds have run this way; the process
below is the distilled shape, including every failure mode they taught.

**A round is 6 per category only while every category still has 6.** The pool is
now uneven and draining unevenly: interaction was exhausted before round 16, and
round 16 emptied color and aesthetic (1 candidate each) while authoring 6 apiece
elsewhere, for 38 terms across 8 agents. Take the whole of a nearly-empty category
rather than skipping it, size the agent list to the categories that still have
terms, and tell a 1-term agent it has one term so it does not pad. Check the real
shape with `pool-remaining.ts` before promising the user a number.

Files in this skill directory:

- `pool-remaining.ts`: lists the unauthored pool per category
- `make-briefs-template.ts`: briefs generator to copy and fill
- `workflow-template.js`: the two-phase agent orchestration to copy and fill

## 0. Preconditions

- Read SPEC.md and AGENTS.md first if this session has not.
- Clean git tree, on `main` (commits go directly to the current branch; never branch).
- The USER runs the dev server on port 4321. Confirm with a GET request to
  `http://localhost:4321/` (Astro dev answers HEAD with nothing, so a HEAD probe
  looks exactly like a server that is down). Never start, stop, or restart any dev server; if it is
  down, ask the user rather than launching one.
- Agent quirk: Astro 7.2 force-backgrounds `astro preview` when it detects an agent, so
  Playwright aborts with "webServer exited early" and runs nothing. `playwright.config.ts`
  now sets `ASTRO_PREVIEW_BACKGROUND=1`; set it by hand for any ad-hoc playwright run.
  Piped through a buffering pipeline this reads as a HANG with an empty log, because the
  orphaned preview holds stdout open. Run e2e detached to a log file and never under a
  tool timeout: the full suite is ~18 min and a 10 min cap kills it mid-run.

## 1. Roster (main session, ~15 min)

1. `bun .claude/skills/authoring-round/pool-remaining.ts` for the per-category pool
   AND the stub backlog, which it now prints as its own list. **Stubs outrank the
   pool.** A stub is a published page carrying a definition and nothing else, already
   linked to by six to nine articles, so promoting one repays a debt the site is
   already visibly carrying. If the stub list is non-empty, the round is a promotion
   round (see §6) unless the user asked for new terms specifically.
2. Pick 6 per category, or the whole remainder where fewer are left. Selection
   principles, in priority order:
   - **Pay prose IOUs**: grep recent articles for "enumerated separately" and named
     cross-references to unauthored terms; an on-site article that NAMES a candidate
     is a debt the candidate's article repays by linking back.
   - Prefer head/core priority; take tail terms when they complete a family or pair.
   - **Build contrast pairs into one round** (fake-urgency/fake-scarcity,
     frame-rate/jank): round-mates get told to contrast each other in one sentence
     each way, and the demos must look different. Only where the pair is REAL, and
     that is a decision made at roster time, not at batching time: batching terms
     together for coordination (shared context, one owner for both sides of an edge,
     lookalike avoidance) must never come with an instruction to write an edge
     between them. Round 23 batched trace-viewer with diff-viewer purely because both
     are dense developer views, told the agent to author them "as a pair, one edge
     between them written on both sides", and got exactly that: a seeAlso each way
     plus a sentence in each article inventing a kinship ("both are dense two-pane
     developer views", "a list beside a detail is what they have in common"). The
     agent had already half-caught it, downgrading contrastWith to seeAlso because
     nobody describing a trace viewer reaches for the word diff viewer, which is the
     discrimination test correctly applied and should have ended in NO edge at all.
     The user caught the rest. Say "these two are yours, keep them from looking
     alike" and let the relation earn its place or not exist.
   - Avoid near-duplicates of existing specimens (a demo that would look like an
     existing one is a smell even when the term is distinct).
   - **Rotate and >2-contact terms wait**: the stage speaks single-contact touch
     (data-touch persona, hold step, pressure; STAGE_NEWS law 17) and the
     two-contact pinch (pinch step, pinchSpread, Ctrl+drag takeover; law 21), so
     pressure, long-press, tap, swipe, and pinch terms are authorable. Rotate and
     gestures past two contacts stay out of rosters until the vocabulary grows
     them (the fake-touch entry in the specimen-sweep ledger tracks it).
3. **Collision-check every pick AND its likely aliases** against
   `src/content/terms/` (`name:` lines catch both term names and alias names).
   A candidate whose name is already an alias of an existing term is DEAD: swap it
   and note nothing. Claimed aliases a surviving pick must not take become fences
   in its brief hint.

   This is now the highest-value step in the roster, not a formality. Do it as a
   script over the whole pool rather than per pick: slugify every alias name in
   every term file into a claimed-slug map, then test each candidate slug against
   it. Round 16 found **19 dead candidates in a 214-term pool**, and they cluster
   in exactly the wrong place: both of accessibility's core candidates
   (landmark-region, tab-order) and two of motion's three head/core
   (shared-element-transition, layout-animation). Priority is no defence.
   Also eyeball the survivors for near-duplicates the slug test cannot see: a
   candidate whose *display name* equals an existing term's name is just as dead
   (motion's `reduced-motion` is the on-site `prefers-reduced-motion`), and the
   pool's own `notes` field often flags merge candidates. The dead list is worth
   reporting to the user, since `candidates.json` still carries them.

   Round 17 found 20 dead this way, then found **four more the slug test cannot
   see and one that escaped the roster entirely**. Two failure modes to add to the
   sweep, both cheap:
   - **Affix variants.** A candidate that is an existing term plus or minus a
     trailing noun shares no slug with it: `back-to-top-button` against the
     published `back-to-top`. This one reached an author agent, which refused it
     as a duplicate and cost the round a slot. Test each candidate slug with its
     trailing `-button`, `-bar`, `-menu`, `-view`, `-pattern` stripped, and with
     the existing slugs' own affixes added.
   - **Definition duplicates.** Read the DEFINITION of the nearest existing term,
     not just its name. `bulk-action-bar` died because bulk-actions' definition
     already says "a bar that appears once a selection exists and reports its
     count"; `session-timeout` died to timeout-warning, `slash-command-menu` to
     slash-command, `color-alone` to use-of-color. Four picks, all with distinct
     slugs, all already written. Grep the candidate's own definition keywords
     against existing definitions before promising the pick.

## 2. Briefs (main session, ~30 min)

Copy `make-briefs-template.ts` to a scratch directory, fill `PLAN` with the round's
slugs and hints (hint anatomy is documented in the template; drop the key for any
category with no terms left), set `OUT` to an absolute scratch path, run it with
bun from the repo root. It hard-fails on any
slug already on site or missing from the pool.

It also prints a `const ROSTER = {...}` line. **Keep that output**: it goes into the
workflow script in step 3 verbatim, and it is what lets the verify gate tell a finished
round from an interrupted one.

## 3. Author + verify (agents, ~30-45 min)

Copy `workflow-template.js`, fill the placeholders:

- `BRIEFS`: the absolute path from step 2.
- `ROSTER`: the line step 2 printed, pasted verbatim. It is the round's contract in
  three places: each author is told its exact slugs and refuses to guess if its briefs
  disagree, the log names any slug no agent reported, and the verify gate is told the
  shape of the round rather than inferring it from what survived. A workflow script has
  no filesystem access, so this literal is the only way the script can know it.
- `CATEGORY_GUIDANCE`: per-category exemplars, IOUs, and alias fences (anatomy
  documented in the template). This is where roster knowledge becomes agent
  knowledge; skimping here costs e2e failures later.
- Never trim `STAGE_NEWS`; append new laws as rounds teach them, and mirror any
  mechanically-checkable new law into `scripts/validate-terms.ts` as a gate.

**Smoke-test the filled script before running it**, because a script that fails to parse
burns the whole launch and reports it as a bare `SyntaxError` with no round attached:

```bash
node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8").replace(/^export const meta/m,"const meta");new Function(`return (async () => {${s}})()`);console.log("parses")' -- /abs/path/to/rN.js
```

`node --check` is the wrong tool here, and worse than nothing. A workflow script's real
evaluation context is an async function body: top-level `await` and a top-level `return`
are both legal in it, and both are errors to `--check` (as CJS it rejects the `await`, as
`.mjs` it rejects the `return`). So `--check` fails on every correct script, which trains
you to dismiss its output, which is how round 25 launched a script whose prompt strings
contained unescaped backticks. Wrapping the source in a function body parses it the way
the runner will and reports only real faults. It earned its keep immediately: the first
run of it over the edited template caught a missing comma in a prompt array.

Run it with the Workflow tool if available (one author agent per stocked category, in parallel, then the
verify agent). Without a Workflow tool, run the same prompts as parallel subagents
and the verify prompt as one subagent after all authors return. Authors do their
own visual pass against the user's dev server; the verify agent runs
validate/typecheck/biome once and fixes what fails. Nobody runs e2e but you.

Authors also run `AUDIT_PASS`: `vd-stage.audit()` plays a specimen's choreography
through the real attract player on the dev server and reports the same triple
`e2e/choreography.e2e.ts` asserts (no failures, exactly one subject, not
interrupted). Round 17 is why it is in the template: three of seven agents found
the API unprompted, audited their own specimens, and the round came back
**96/96 green on the first `test:e2e:new`**, with zero behavioral fixes needed
for the first time in the process. The other four flew blind and got lucky. It is
a dev-server pre-check, not the gate (dev injects CSS through JS, so a specimen
can mount before the stage has its height), so a failure there is always real
while a pass is only very likely: you still run the full suite.

**Refused terms** (STAGE_NEWS law 18): an author who finds a term needs input or
stage machinery the player does not have returns it unauthored with
`needs stage primitive: <what>` instead of faking it with a simulation control.
Collect these in the report, tell the user (building the primitive is a main-session
or dedicated-session job, like `hold` and the touch persona were), and keep the term
out of rosters until the primitive lands. Faking the player is exactly what the
fake-touch sweep is un-doing; never mint new offenders.

**Interrupted-agent recovery** (three times now: twice a spend limit, once an API
connection lost mid-response): inventory which slugs have all three files
(`terms/<slug>.mdx`, `demos/<slug>/demo.ts`, `demos/<slug>/choreography.ts`), then
relaunch a finish variant: per-category agents get only their REMAINING slugs, are told
which batch-mates are DONE (read them for idiom, do not rewrite), treat partial files as
drafts from their earlier self, and run the visual sweep over the WHOLE category batch
(dead agents may not have visually checked their own later terms; the sweep has caught
real overflow bugs in "complete" specimens every time). The verify agent then gates the
whole round.

An agent that dies mid-response returns NOTHING, so its finished terms are absent from
`completed` and invisible to anything reading only the agents' reports. That is what the
`ROSTER` fill is for. Round 25 is the worked example: an author died having written
`wireframe` of a three-term fidelity ladder, and the verify gate, told only what the
surviving agents reported, found a published term contrasting two slugs that did not
exist. It did the SPEC 2.3-sanctioned thing and minted `mockup` and `prototype` as
STUBS, which is right in isolation and is exactly the debt the family was batched whole
to avoid. The gate now knows the roster and is told the opposite rule for slugs on it:
leave the dangling relation dangling, let `bun validate` fail, and report the gap in its
`incomplete` field, because a stub there converts a recoverable interruption into
published debt signed by the gate. A stub is still correct for a slug the round never
planned, and the gate says so explicitly when it mints one.

## 4. e2e and commit (main session, ~30 min)

1. `bun run test:e2e:new` plays only specimens without a committed subject
   snapshot; first run fail-writes the new snapshots. **Ask before starting it**, and
   never run the full `bun run test:e2e` unasked: nothing runs it automatically any more
   (`ci.yml` is static gates only, e2e is manual in `e2e.yml`), so a 45-minute pass is
   45 minutes of the user's session. Fix behavioral failures and re-run targeted until
   green.

   **Run it in the round that authored the specimens, even if the round is otherwise
   done.** The gate finds its work by the ABSENCE of a snapshot, so skipping it leaves
   no trace of the omission: nothing fails, nothing warns, and the round commits
   looking complete. Round 24 shipped 38 specimens with no record anywhere of what any
   of them identifies as, and the only reason anyone found out is that round 25's run
   came back reporting 47 new specimens instead of nine, at four times the wall clock.
   If the user declines the run, say in the report that the round's specimens are
   unproven and that the next round will pay for it.

   The failure taxonomy so far, most common first:
   - an assert timed to the edge of a state window (give it room, aim mid-window)
   - an assert on evidence inside a popup the action just closed (mirror onto trigger)
   - a mount-time assert with no room for kit fades (open with a wait)
   - invalid selector syntax and hairline subjects (now validate-gated)
   - an assert on a wrapper with no box: a group whose children are all absolutely
     positioned, or one held at `height: 0` to overlay without shifting, measures zero
     and reads as absent just like a hairline does
   - a demo that answers input by synthesizing more input (`btn.click()` inside a
     click handler): the choreography passes and the TAKEOVER pass fails, because
     it counts the clicks reaching the specimen and wants exactly one
   - a mid-flight assert on an element that is fading OUT (STAGE_NEWS law 28). The
     tell is an identical claim passing inbound and failing outbound, so it reads
     as flake rather than as a rule; it is not one, and widening the window cannot
     fix it. Claim the exit through something that stays visible.
   - an assert whose meaning does not match the element's (STAGE_NEWS law 29): a
     cumulative claim aimed at a "last event" readout. Look for its twin while
     fixing it, an assert that passes for a reason the demo's state axis never
     touched, which is invisible precisely because it is green.
2. **Review the new subject snapshots** (`e2e/__snapshots__/<slug>-subject.txt`):
   every one should be `scope: element` with a sensibly narrow subject; every
   `data-pose` carrier must mount in a state satisfying its pose (the attributes in
   the snapshot show both). A `whole scene` scope withdraws identify: verify the
   agent noted that decision deliberately.
3. No full `bun run test:e2e`, locally or in CI. If the round's specimens should be
   proven before release, say so in the report and let the user trigger the manual
   Specimens workflow, scoped with `grep` if only some terms changed.
4. Check for stray files (authors sometimes leave `shot-*.png` or probe scripts at
   the repo root); move them out, never commit them.
5. `bun run og --build` shoots the share images (SPEC §10). It builds, then
   photographs every specimen that has no image yet, which after a round is exactly
   the new terms: seconds of shooting on top of the build. Not optional. A term page
   names `/og/<slug>.png` whether or not the file exists, so a new term without one
   ships a broken link preview, and nothing anywhere checks for it.
6. Commit everything as `Author an Nth round of 6 terms per category`. The tree
   must be clean after.

## 5. Report

Tell the user: terms count before/after (from `bun validate`'s summary), e2e check
count, behavioral failures found and how they were fixed (each is a candidate new
law for STAGE_NEWS), notable agent decisions (subject choices, dropped aliases,
deviations from briefs), and the remaining pool size.

The consolidated relations pass has run (2026-08-21), so a new term no longer ships
with empty `relations`: author its edges in the round, against the discrimination
test in SPEC §2.3, and give a term whose family already has a head term a `variantOf`
rather than a new contrast. Tags are authored too, since the corpus backfill
(2026-08-29): give every term the tags it belongs to FROM THE ENUM AS IT STANDS, read
off how the same facet was applied to that term's own family rather than from the tag
name alone. What a round may not do is grow the enum: a tag invented while authoring
nine terms is a tag chosen without knowing who else wants it, so record the candidate
in research/enumeration/canonicalize-notes.md and let it be settled by a pass over the
whole vocabulary (SPEC §2.5).

## 6. Promotion rounds

A promotion round finishes stubs instead of authoring new terms. Everything above
applies, with five differences that all come from the same fact: **the term file
already exists and is already linked to.**

- **Roster.** The stub list from step 1 IS the roster, so there is no pool selection,
  but the collision sweep matters MORE rather than less: a stub written as a relation
  target a year of rounds ago may since have been authored under another name.
  Check each stub's definition against its nearest published neighbours, not just its
  slug, and be willing to return one as a merge (an alias on the neighbour) rather
  than force a page. The 2026-08-21 round checked 24 and merged none, but two
  (`zero-state` against empty-state, `semantic-color` against color-role) needed the
  existing graph consulted before they survived.
- **Briefs.** Per term they carry, beyond the usual: the MANDATORY reciprocations
  (every symmetric edge a published term already declares at the stub, which symmetry
  stopped requiring only because it was a stub, and which the gate demands the moment
  it publishes), the prose IOUs (the articles already linking in, which the new
  article must agree with and link back to), and a per-term lookalike fence naming the
  neighbour demo it must not duplicate. Compute the reciprocations mechanically; they
  are not guessable.
- **Relations are declared one-sided, on purpose.** Authors write relations ONLY in
  their own files and never touch another term's frontmatter, because seven agents
  editing shared neighbours is a lost-write race. `bun validate` then reports one
  symmetry error per new outbound edge, which is expected, not a failure: the main
  session closes them centrally afterwards (the closure script is trivial and worth
  keeping). Check the eight-contrast hub cap after closure, not before: the
  2026-08-21 round pushed `select` to nine and had to drop its weakest new edge.
- **Tags are the main session's, not the authors'.** Assign them centrally, matching how
  the facet was applied to that term's own family (read the neighbours' tags), rather than
  letting each agent invent its own reading of the enum.
- **Editing in place.** Keep `name`, `slug` and `category` byte-identical: other pages
  link to that slug. A definition may be sharpened only after reading the articles
  that link in, because their prose was written against it, and a sharpening that
  makes them false is a regression. Have the verify agent diff all three fields
  against `git show HEAD:` and report drift.

