The only wrong number was the one a human typed

Ghostwriter · No. 110

Shipped

A new card type composes a one-page brochure for a shipped release: nameplate, headline, the refusal the skill makes in its own words, the install line, and one proof figure. Underneath it, a script reads the version, tag, publish date and install commands off the released artifact and refuses outright for a skill with no published release. A scaffold command writes the card with every factual slot already filled and every judgment slot left marked TODO.

Then the second card went out with a number that was wrong by half, and the number was the only thing on it a person had typed.

The split worth stealing

The interesting thing about generating a document is not the templating. It is deciding which slots a machine is allowed to fill.

Some slots have exactly one correct value and it exists somewhere already: the version, the tag, the publish date, the install command. Nobody should be typing those, because typing them means the card is correct only for as long as the person who typed it was paying attention.

Other slots have no correct value anywhere on disk. The headline. What this thing is actually for. Which of the skill’s rules to quote. A script that fills those produces the same card every time, which is another way of saying it produces a card nobody reads.

So the rule is: fill every fact from the artifact, mark every judgment, and let a gate refuse anything still carrying a mark.

This is DRY applied to a document rather than to code. The Pragmatic Programmer states it as every piece of knowledge having “a single, unambiguous, authoritative representation within a system”, and the failure mode it warns about is exactly what happened here: the same fact lived in two places, one of them went stale, and the stale one was the one that got read.

Read the facts off the artifact

"""Read the facts a generated card is allowed to claim, off the released artifact."""
from __future__ import annotations


def semver_key(tag: str, prefix: str) -> tuple[int, ...]:
    """Sort key for a namespaced tag like `widget-v0.10.0`.

    Parsed to integers, never compared as text: `0.10.0` sorts BELOW `0.9.0`
    as a string, which silently advertises an older release as the newest.
    """
    body = tag[len(prefix):].lstrip("v")
    parts = (body.split("-", 1)[0]).split(".")
    return tuple(int(p) if p.isdigit() else 0 for p in (parts + ["0", "0", "0"])[:3])


def latest_release(releases: list[dict], component: str) -> dict | None:
    """The newest published release whose TAG BELONGS TO THIS COMPONENT.

    In a monorepo a repo-wide "latest" is the wrong answer: it prints another
    component's version under this one's name.
    """
    prefix = f"{component}-v"
    mine = [
        r for r in releases
        if r["tagName"].startswith(prefix)
        and not r.get("isDraft") and not r.get("isPrerelease")
    ]
    if not mine:
        return None
    return max(mine, key=lambda r: semver_key(r["tagName"], prefix))


def build_facts(releases: list[dict], component: str, slug: str, marketplace: str) -> dict:
    """Every slot a machine can fill. Refuses outright rather than blanking one."""
    rel = latest_release(releases, component)
    if rel is None:
        raise SystemExit(
            f"{component}: no published release. A card advertising a version "
            f"nobody can install is the one failure it cannot survive."
        )
    prefix = f"{component}-v"
    return {
        "component": component,
        "version": rel["tagName"][len(prefix):],
        "tag": rel["tagName"],
        "publishedAt": rel["publishedAt"][:10],
        # BOTH steps. The install command alone does nothing until the
        # marketplace is added, so a card showing one advertises a path that
        # does not work.
        "installSteps": [
            f"/plugin marketplace add {slug}",
            f"/plugin install {component}@{marketplace}",
        ],
    }

releases is passed in so this stays testable. In real use it comes from gh release list --repo <slug> --limit 200 --json tagName,publishedAt,isDraft,isPrerelease, which the GitHub CLI manual documents as listing releases with those fields, defaulting to 30 items and newest-first.

Three filters, and each one is a different way of being confidently wrong. Draft and prerelease entries are tags that exist without anything installable behind them. The component prefix is what stops a monorepo from printing a sibling’s version under this name. And the sort is numeric, because Semantic Versioning compares major, minor and patch numerically and a text sort puts 0.10.0 below 0.9.0, which is the kind of bug that only appears once you reach a tenth release and then looks like a caching problem.

The refusal at the top of build_facts matters more than it looks. Rendering a brochure with a blank version is worse than rendering nothing, because a blank gets filled in later by whoever is closest.

Fill the facts, mark the judgment

"""Fill every fact slot; leave every judgment slot marked TODO."""
from __future__ import annotations

TEMPLATE = """<article class="card" id="{example_marker}">
  <p class="nameplate">{component} v{version} · {published}</p>
  <h1>{headline}</h1>
  <p class="standfirst">{standfirst}</p>
  <code class="cmdbar">{install_1}</code>
  <code class="cmdbar">{install_2}</code>
  <blockquote class="refusal">{refusal}</blockquote>
  <p class="proof">{proof}</p>
</article>
"""

# Judgment slots. Nothing on disk answers these, so the scaffold marks them
# rather than guessing, and the lint refuses a card that still carries one.
JUDGMENT = {
    "headline": "TODO headline with one signature pivot",
    "standfirst": "TODO one sentence on what this is for",
    "refusal": "TODO quote the skill's own refusal, do not paraphrase it softer",
    "proof": "TODO one figure you COUNTED today from the artifact",
}


def scaffold(facts: dict) -> str:
    return TEMPLATE.format(
        example_marker="card-real",
        component=facts["component"],
        version=facts["version"],
        published=facts["publishedAt"],
        install_1=facts["installSteps"][0],
        install_2=facts["installSteps"][1],
        **JUDGMENT,
    )

Writing the instruction into the placeholder is worth the extra characters. “TODO headline” tells you a slot is empty; “TODO quote the skill’s own refusal, do not paraphrase it softer” tells you what a correct answer looks like, at the moment you are about to write a wrong one. The placeholder is the only documentation anybody reads, because it is the only documentation that appears in front of them while they work.

Gate the composed card

"""The gate. Runs on the composed card, before anything renders."""
from __future__ import annotations

import re


def lint_card(html: str, facts: dict) -> list[str]:
    findings = []

    # 1. No judgment slot may ship unfilled.
    if "TODO" in html:
        left = re.findall(r"TODO[^<\"]*", html)
        findings.append(f"{len(left)} judgment slot(s) still marked TODO: {left[0]!r}")

    # 2. The demo card must never ship as somebody's release.
    if 'id="card-example"' in html:
        findings.append("this is the example card, not a real one")

    # 3. Both install steps, or the path it advertises does not work.
    shown = html.count('class="cmdbar"')
    if shown < len(facts["installSteps"]):
        findings.append(f"shows {shown} install step(s), needs {len(facts['installSteps'])}")

    # 4. Every fact slot must match the artifact, not something retyped near it.
    for label, value in (("version", facts["version"]), ("date", facts["publishedAt"])):
        if value not in html:
            findings.append(f"{label} {value!r} from the release is not on the card")

    return findings

Check 3 is there because of a real failure. The card originally advertised a single install command, and that command does nothing on its own: the marketplace has to be added first. One line, correct in isolation, describing a path that does not work. That is precisely the failure the facts script exists to prevent, arriving through the half of the install path the script was not surfacing yet.

Check 2 exists because the template ships with an example card, and an example that can render as somebody’s release eventually will.

Run it

from facts import build_facts, latest_release, semver_key
from scaffold import scaffold
from cardlint import lint_card

# Stand-in for `gh release list --repo <slug> --limit 200 --json
# tagName,publishedAt,isDraft,isPrerelease`.
RELEASES = [
    {"tagName": "widget-v0.9.0", "publishedAt": "2026-07-02T10:00:00Z", "isDraft": False, "isPrerelease": False},
    {"tagName": "widget-v0.10.0", "publishedAt": "2026-08-04T09:12:00Z", "isDraft": False, "isPrerelease": False},
    {"tagName": "widget-v0.11.0", "publishedAt": "2026-08-05T09:00:00Z", "isDraft": True, "isPrerelease": False},
    {"tagName": "gadget-v3.1.0", "publishedAt": "2026-08-05T11:00:00Z", "isDraft": False, "isPrerelease": False},
]

# 1. Sorting tags as text picks the wrong one.
published = [
    r["tagName"] for r in RELEASES
    if r["tagName"].startswith("widget-v") and not r["isDraft"] and not r["isPrerelease"]
]
print("candidates        :", published)
print("string sort picks :", max(published))
print("semver sort picks :", max(published, key=lambda t: semver_key(t, "widget-v")))

# 2. A repo-wide "newest" belongs to a different component, and the newest tag
#    of all is an unpublished draft.
print("repo-wide newest  :", max(RELEASES, key=lambda r: r["publishedAt"])["tagName"])
all_widget = [r["tagName"] for r in RELEASES if r["tagName"].startswith("widget-v")]
print("highest widget tag:", max(all_widget, key=lambda t: semver_key(t, "widget-v")), "(a draft)")
print("this component's  :", latest_release(RELEASES, "widget")["tagName"])

facts = build_facts(RELEASES, "widget", "acme/widgets", "acme-marketplace")
print("\nfacts:", facts)

# 3. The scaffold fills facts and marks judgment.
card = scaffold(facts)
print("\nscaffolded card:")
print(card)

print("lint on the scaffold (should refuse):")
for f in lint_card(card, facts):
    print(" -", f)

# 4. A composed card, with the judgment slots actually written.
composed = card
for slot, text in [
    ("TODO headline with one signature pivot", "Ships what it can <span>prove.</span>"),
    ("TODO one sentence on what this is for", "Cuts a release and reads the tag back from origin."),
    ("TODO quote the skill's own refusal, do not paraphrase it softer",
     "Never reports a release until the tag is fetched back from the remote."),
    ("TODO one figure you COUNTED today from the artifact", "121 published guides"),
]:
    composed = composed.replace(slot, text)

print("\nlint on the composed card:", lint_card(composed, facts) or "clean")

# 5. Non-vacuity: the gate must still refuse a card missing an install step.
one_step = composed.replace(f'<code class="cmdbar">{facts["installSteps"][0]}</code>\n  ', "")
print("lint on a one-step card:", lint_card(one_step, facts))

assert lint_card(card, facts), "the gate must refuse an unfilled scaffold"
assert not lint_card(composed, facts), "a fully composed card must pass"
assert lint_card(one_step, facts), "the gate must refuse a broken install path"
print("\nall checks passed")

Save the three blocks as facts.py, scaffold.py and cardlint.py, this one as check.py, and run python3 check.py. It is stdlib only. Here is what it printed for me:

candidates        : ['widget-v0.9.0', 'widget-v0.10.0']
string sort picks : widget-v0.9.0
semver sort picks : widget-v0.10.0
repo-wide newest  : gadget-v3.1.0
highest widget tag: widget-v0.11.0 (a draft)
this component's  : widget-v0.10.0

facts: {'component': 'widget', 'version': '0.10.0', 'tag': 'widget-v0.10.0', 'publishedAt': '2026-08-04', 'installSteps': ['/plugin marketplace add acme/widgets', '/plugin install widget@acme-marketplace']}

scaffolded card:
<article class="card" id="card-real">
  <p class="nameplate">widget v0.10.0 · 2026-08-04</p>
  <h1>TODO headline with one signature pivot</h1>
  <p class="standfirst">TODO one sentence on what this is for</p>
  <code class="cmdbar">/plugin marketplace add acme/widgets</code>
  <code class="cmdbar">/plugin install widget@acme-marketplace</code>
  <blockquote class="refusal">TODO quote the skill's own refusal, do not paraphrase it softer</blockquote>
  <p class="proof">TODO one figure you COUNTED today from the artifact</p>
</article>

lint on the scaffold (should refuse):
 - 4 judgment slot(s) still marked TODO: 'TODO headline with one signature pivot'

lint on the composed card: clean
lint on a one-step card: ['shows 1 install step(s), needs 2']

all checks passed

Four lines at the top, four different wrong answers, all of them plausible. The string sort, the repo-wide newest, the highest tag including a draft, and the right one. Any of the first three would have rendered a card that looked completely fine.

Gotchas

The slot with no rule is the slot that goes stale. Every fact on the second card was read from the release and every one was right. The proof figure was not, because nothing said where it had to come from, so it was copied out of a project document that said 61. Counting the actual published items gave 121. The document had simply not been updated in a while, which is what documents do. The fix was to give that slot the same kind of rule the others already had: count it from the artifact today, never quote it from a README, a design doc or a changelog note, and drop the figure entirely rather than ship one you cannot count. A slot with no provenance rule will be filled from whatever is nearest, and what is nearest is usually a document rather than the thing itself.

A parser that knows one dialect reports “empty”, not “unsupported”. The changelog reader here matched one heading style. This repo writes two. Against the other style it returned an empty list, which downstream read as a release that changed nothing, rather than as a parse failure. When a reader finds nothing, make it say whether it found nothing or understood nothing. Those look identical in a result and are opposite in a diagnosis.

Do not let a missing input become a silent blank. One skill declares no single headline rule, and the quote slot rendered empty. Empty is the worst of the three options, because it invites an invented promise. Say what to do instead: quote it from the release notes, or drop the element. A generator that cannot produce a slot should say so in the slot.

Measure the box you actually care about. A layout check here compared a chip’s border box against a line height, so any generously padded command chip was reported as wrapping while sitting comfortably on one line. Switching it to the content box fixed it. It surfaced while building this card and would have nagged every card with a prominent command in it.

Assert the gate still refuses. The last three lines of the run script are assertions that an unfilled scaffold fails, a composed card passes, and a card with a broken install path fails. Only the middle one is about today’s output. The other two are what stop the gate from quietly becoming a function that returns an empty list.

Sources

Changelog

  • fix(ghostwriter): three evidence gaps the second brochure exposed (#184) (037dc56)
  • feat(ghostwriter): a brochure card for a shipped release, built from the release itself (0.15.0) (#183) (79b7eaf)