$engineering
memos

how we author

Colophon

The harness that drafts these records and narrates the memos, read from the repo at build: two agent-driven authoring skills, a voice spec, in-body components, a narration toolchain, and a rendering pipeline. Reproducible steps are code; judgment stays prose.

substrate as of 9a6fb73 · 29 files · build-time snapshot

.agents/records/authoring.md229 lines
# Authoring a record: the shared procedure

The procedure a record runs whatever its kind, written once. `/memo` and `/slip` each own their own
lifecycle and point here for the rest. Parts of it apply with no skill running, which Loading the
register below sets out. What a memo is, what a slip is, and where the line between them sits are in
`.agents/records/kinds.md`.

## Contents

- The conversational loop
- The state notation
- Author resolution
- The author registry
- The published slug
- Loading the register
- Promote, publish, retire
- The archive procedure
- Converting a record

## The conversational loop

Each cycle follows the same pattern:

1. User inputs a command.
2. Skill creates or detects state: folder, files, content.
3. Skill shows current state, using the notation below.
4. Skill presents the options available for that state.
5. User responds, either picking one of the listed actions, or continuing the conversation.
6. If an action: process and update the file. If conversation: respond in chat only; artifacts stay
   untouched.
7. Skill shows result: preview and confirmation for actions; just the reply for conversation.
8. Loop returns to step 3 after actions. After a conversation turn, stay in conversation; no need to
   re-render state until the next action.

**Artifacts are only written when the user picks a listed action.** Free-form follow-ups, questions,
reactions, pushback, thinking out loud, are conversation, not instructions to revise. Treat a
follow-up as a revise only when the user either explicitly picks a revise option or uses imperative
capture language ("add...", "note that...", "put in research that...", "write down..."). When in
doubt, stay in conversation and ask before writing. **Don't auto-capture.**

Always present the current state's full option set, verbatim from the skill's own list. Recommending
one is fine; omitting the rest is not. The user chooses from the whole menu, never from a subset
curated to the move you happen to favour.

## The state notation

A filled dot (●) marks a stage that exists, a hollow dot (○) one that does not. Position is the
rightmost filled dot, and the next move is the first hollow one. The last two stages are shared:
`filed` means the record exists in its collection at `draft: true`, and `published` means the flag
reads `false`. Each skill states its own full sequence.

Every record passes through `filed` before `published`, so a record under review is on its real URL
with the draft bar showing, and publishing is a decision of its own.

## Author resolution

Creating a record writes `meta.yaml`, the per-record seed and author identity:

```yaml
kind: slip                         # slip or memo; what the skill's lifecycle keys off
idea: "the rough idea, verbatim"   # the create argument, recorded as the record's immutable seed
author: "John Doe"                 # display name captured at create; the canonical byline lives in authors.yml
handle: john-doe-unipaas           # GitHub handle -> frontmatter `author` reference at promote, and per-author voice
```

`kind` is `slip` or `memo`, and it decides which skill owns the record. A `meta.yaml` with no `kind`
reads as `memo`, because the workspace was memo-only before slips existed. Both skills resolve an id
against the same `records/wip/` and `records/archived/`, so a skill handed an id whose `kind` names the
other kind recognises that and hands the record to the other kind's skill rather than proceeding.

`idea` is recorded verbatim from the create argument: the immutable seed the record keeps even as any
living interpretation of it evolves. Capture `author` and `handle` by prompting the driver, with these
defaults:

- `handle`: `gh api user -q .login`
- `author` (the byline): `gh api user -q .name`; if that is empty, fall back to `git config user.name`

Show the resolved defaults and let the driver override either before writing `meta.yaml`. Once
written, `meta.yaml` is not regenerated on a later invocation for the same record; it is respected
like any other manually-edited artifact. `meta.yaml` is what promote reads the author `handle` from,
written verbatim as the frontmatter `author` reference, which must be a key in `authors.yml`. For a
memo it is also what every render passes as `--handle`. It exists independent of the other artifacts
and does not appear in the state notation.

## The author registry

Author-level identity, captured once at a record's first promote and reused across everything that
author files. Every kind runs this identically.

- **Author registry.** If the handle is already a key in `authors.yml` and its entry has a `photo`,
  continue. If the entry exists but has no `photo`, gently re-offer one (a single skippable line): a
  byline photo is expected, never required. If the handle is not a key (a first-time author's first
  promote), capture a complete entry now: take `name` from `meta.yaml`'s `author`, prompt for `role`
  and narration `voice` (offer the house default from `memo.yml`), optionally `bio` and `links`, and
  offer a byline photo (below), then append the entry to `authors.yml` keyed by the handle. A
  first-time author's drafting previews rendered in the house voice, because their entry did not yet
  exist; once capture sets their voice, prompt a final listen at the promoted route so the take they
  approve is the take that ships.
- **Author photo.** Author-level identity, captured once and reused across the author's records. Every
  option that yields a photo yields a committed file under `site/src/assets/authors/`, so `photo` is
  always a filename and never a URL: the site is static and nothing it builds should depend on a third
  party being reachable. Offer one enumerated choice (rendered as a picker where the harness supports
  it):
  - *Supply a headshot:* the author gives a path to a real image on their machine; copy it to
    `site/src/assets/authors/<handle>.<ext>` (extension from the source) and set `photo` to that
    filename. The file is committed with the record. Only record what the author supplies; never
    generate or edit a face.
  - *Use my GitHub avatar:* fetch `https://github.com/<handle>.png` once (the handle from
    `meta.yaml`), write it to `site/src/assets/authors/<handle>.<ext>`, and set `photo` to that
    filename. Two details: follow the redirect, since that URL always redirects to
    `avatars.githubusercontent.com`, and take `<ext>` from the response's `content-type` rather than
    from the URL, which says `.png` while commonly serving JPEG. On a failed fetch, say so and fall
    back to the skip option; a photo never blocks a promote.
  - *Skip for now:* leave `photo` unset; the byline renders the silhouette. It can be added later by
    editing `authors.yml` and dropping a file in `site/src/assets/authors/`.

## The published slug

Run `uv run tools/records/slug.py "<title>"` (which calls `lib.slugify`); never hand-roll it. One tested
computation (ASCII-fold, lowercase, non-alphanumeric runs collapsed to single hyphens, ends trimmed)
keeps the published slug identical across the record file, its `/memos/<slug>` route, and a memo's
mp3, and stays stable on awkward titles (punctuation, accents, doubled spaces).

**Two slugs, by design.** The workspace folder slug is a human label, distilled from the idea to a few
words and then run through `slug.py`; the 4-char id is the stable key. Distil first, because `slug.py`
never truncates: a sentence-length idea handed to it whole yields a folder name past 150 characters.
The published URL slug is derived from the record's final title at promote. They can differ, because
the title is only settled once the record exists.

**The id** is 4 random alphanumeric characters (a-z, 0-9), generated at create and shared by every
kind. It keeps two records distinct even when their folder slugs collide, and it is the key every
skill resolves `<id>` against.

## Loading the register

The shared spine (`.agents/rules/voice.md`) is path-scoped to the workspace, so every draft loads the
house voice automatically. A kind's own register is scoped to that kind's collection, which is the case
where a file can be edited with no skill running.

Inside the workspace, load the register explicitly: read `kind` from `meta.yaml` and read
`.agents/rules/voice.memo.md` for a memo or `.agents/rules/voice.slip.md` for a slip before writing or
revising a draft. There is always a skill driving here, because the workspace exists because a skill
made it.

## Promote, publish, retire

Three separate decisions, and keeping them separate is the point.

**Promote** copies the record into its collection under `site/src/content/<kind>s/<slug>.md` with
`draft: true`. Shared items, in order: the title check (an H1 that is present and is not a
placeholder), the content check (title and body both present), the slug from
`uv run tools/records/slug.py "<title>"`, the author handle read from `meta.yaml`, and the author registry
(above). Each kind adds what its schema requires and nothing more.

A promoted record is filed rather than published. It renders under `npm run dev` at its real URL,
carrying the draft bar and `[draft]` where its ref will go, which is the surface to review it on. It
reaches no reader: a production build contains no draft. From promote onward the collection file is the
record's canonical body and the workspace folder is the paper trail, so revising a filed or published
record edits the collection file.

**Publish** is its own action, available once a record is filed. Two steps, and the second is the one
that matters:

1. Set `draft: false` in the record's frontmatter.
2. Set `publishDate` to now, as a full ISO timestamp (`2026-08-19T14:30:00Z`) rather than a bare day.

Refreshing the date is not housekeeping. Refs are derived from the record set at build time, so a
record filed with a date earlier than the day it publishes renumbers every record filed after it,
across every kind, and no check can catch it. A record held at `draft: true` carries a date that goes
stale while it waits, so a draft held past a later record's publication is backdated by the delay. Say
the old date and the new one out loud when you change it.

**Write a time, not just a day.** Two records publishing on one day is ordinary, and records carrying
one identical timestamp fall to a tie-break that sorts kind before id, so `memo` precedes `slip` and a
memo published this afternoon takes the number a slip published this morning already carries. That
renumbering needs nobody to type a wrong date, which is why it sits outside the backdating rule above.
A time makes the sequence the order things actually published in. Nothing on the page changes: every
surface that prints a date renders the day alone, and the structured data gains the precision it wants.

Publishing does not reach readers either. The record ships when the branch merges to `main` and the
deploy runs.

**Retire** moves the workspace folder to `records/archived/<id>-<slug>/`, per The archive procedure
below. It
runs after a record is published and it is the only one of the three that touches the workspace. It
leaves the published record where it is.

**Archive** is the same move on the workspace folder, and the two differ in what they mean: archive
abandons a record that never published, retire finishes one that did. Archiving a record that was
already filed also removes its collection file, because a record nobody will publish would otherwise
linger as a permanent draft on the dev register; the folder still moves to `records/archived/` and
stays the paper trail, so nothing is lost. A published record retires.

## The archive procedure

The move itself, run identically by every kind and by both callers, the author abandoning a record and
retire finishing a published one. Which of the two applies, and what each leaves behind in the
collection, is above.

- Move `records/wip/<id>-<slug>/` as-is to `records/archived/<id>-<slug>/` (create `records/archived/`
  if it doesn't exist). As-is means every artifact, dotfiles included; nothing is dropped or deleted.
- Idempotent: if `records/archived/<id>-<slug>/` already exists and `wip/` has no folder for the id,
  the record is already archived. Say so and carry on rather than treating it as an error. Never copy
  an archived folder back into `wip/`, and never merge two folders for one id.
- Show: "Archived <id>-<slug> to records/archived/"
- When the caller is the author abandoning the record, there are no further options: the record is no
  longer WIP. When the caller is retire, the record stays reachable by id and its options are
  unchanged, per the state resolution in the skill that owns the kind.

## Converting a record

Available from the first draft until a record publishes, and initiated by the author alone. No skill
suggests a conversion. Quote the test in `.agents/records/kinds.md` when the author asks for one, so the
decision is made against the written boundary.

**In the workspace**, converting is one field. Set `kind` in `meta.yaml`. Going from slip to memo,
render `research.md` from `.agents/skills/memo/references/research.yaml` per the template contract,
empty and ready to fill. Going from memo to slip, `research.md` is carried along untouched: nothing
here is ever deleted, and the slip lifecycle has no use for it.

**For a record already filed** at `draft: true`, also move the collection file between
`site/src/content/slips/` and `site/src/content/memos/`, and add or drop `description`. Going to a
memo, prompt for the one-line description or draw it from the body's opening. Going to a slip, drop the
field. The URL does not change, because every kind renders under `/memos/<slug>`.

**Converting a published record is refused.** A ref's prefix encodes the kind, and a ref is a published
record's citable number, so changing the kind breaks a citation someone may already hold. Say that, and
offer the two things that are available: file a new record of the other kind, or leave it and link the
two in prose.