<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>cairns</title>
<subtitle>A worklog kept as numbered markdown entries: write them, check them, publish them.</subtitle>
<id>https://toyz.github.io/cairns/</id>
<link rel="self" href="https://toyz.github.io/cairns/feed.xml"/>
<link rel="alternate" href="https://toyz.github.io/cairns/"/>
<link rel="alternate" type="application/json" href="https://toyz.github.io/cairns/log.json"/>
<updated>2026-09-24T00:00:00Z</updated>
<generator>cairns 0.0.0-dev</generator>
<entry>
<title>An entry written in one command, prose on stdin</title>
<id>https://toyz.github.io/cairns/24-an-entry-written-in-one-command-prose-on-stdin</id>
<link rel="alternate" href="https://toyz.github.io/cairns/24-an-entry-written-in-one-command-prose-on-stdin"/>
<updated>2026-09-24T00:00:00Z</updated>
<category term="cli"/>
<category term="skill"/>
<summary>Writing an entry was two steps: cairns new made a stub, then the prose went into it by editing the file.</summary>
<content type="html">&lt;p&gt;Writing an entry was two steps: &lt;code&gt;cairns new&lt;/code&gt; made a stub, then the prose went
into it by editing the file. A model doing this got the second step wrong often
enough to matter - looking for the file, rewriting the front matter, or
repeating the heading. And the &lt;code&gt;**Still unknown:**&lt;/code&gt; line was the part it got
wrong most: left blank, written twice, or put somewhere other than the end.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cairns new&lt;/code&gt; now takes the prose directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;cairns new &quot;Title&quot; --area cli --unknown &quot;what is open&quot; --body - &amp;lt;&amp;lt;&#39;EOF2&#39;
The prose.
EOF2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--body -&lt;/code&gt; reads stdin, and &lt;code&gt;--body &quot;text&quot;&lt;/code&gt; takes it inline. The command writes
the front matter, the &lt;code&gt;# N. Title&lt;/code&gt; heading and the trailer, so nothing has to
be opened afterwards. Without &lt;code&gt;--body&lt;/code&gt; it writes the stub exactly as before -
&lt;code&gt;without_a_body_the_stub_is_unchanged&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;one-way-for-the-trailer-to-arrive&quot;&gt;One way for the trailer to arrive&lt;/h2&gt;
&lt;p&gt;The trailer comes from &lt;code&gt;--unknown&lt;/code&gt;, or from a &lt;code&gt;**Still unknown:**&lt;/code&gt; line already
at the end of the body. Never both, and never neither:&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;body has a trailer&lt;/th&gt;&lt;th&gt;&lt;code&gt;--unknown&lt;/code&gt;&lt;/th&gt;&lt;th&gt;result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;absent&lt;/td&gt;&lt;td&gt;the body as written&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;yes&lt;/td&gt;&lt;td&gt;given&lt;/td&gt;&lt;td&gt;refused - two trailers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;given&lt;/td&gt;&lt;td&gt;appended&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;no&lt;/td&gt;&lt;td&gt;absent&lt;/td&gt;&lt;td&gt;refused, naming &lt;code&gt;--unknown nothing&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Refusing the last case rather than defaulting to &lt;code&gt;nothing&lt;/code&gt; is deliberate. A
default of &lt;code&gt;nothing&lt;/code&gt; would close out every entry whose writer forgot, which is
the silent hole &lt;a href=&quot;https://toyz.github.io/cairns/17-an-entry-that-says-nothing-about-what-it-does-not-know/&quot; title=&quot;An entry that says nothing about what it does not know&quot;&gt;17&lt;/a&gt; made &lt;code&gt;check&lt;/code&gt; close. The refusal happens before a number
is taken, so a refused body leaves no stub behind.&lt;/p&gt;
&lt;p&gt;&quot;Has a trailer&quot; means the marker begins a line, as the spec requires. The first
version of the MCP check used &lt;code&gt;contains&lt;/code&gt;, which counts a sentence that merely
mentions the marker - this entry is one. Both paths now share &lt;code&gt;has_trailer&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A body that starts with a &lt;code&gt;# heading&lt;/code&gt; loses it, because the command writes the
entry&#39;s own and a repeated heading is the most common thing handed over with
the prose. Tests: &lt;code&gt;a_body_takes_its_trailer_from_unknown&lt;/code&gt;,
&lt;code&gt;a_body_that_ends_with_a_trailer_keeps_it&lt;/code&gt;,
&lt;code&gt;a_body_with_no_trailer_at_all_is_refused_with_the_fix&lt;/code&gt;,
&lt;code&gt;a_body_that_repeats_the_heading_loses_it&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-mcp-tool&quot;&gt;The MCP tool&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;worklog_new&lt;/code&gt; already took a body, but always appended its own trailer, so a
body that ended with one got two. It now goes through the same &lt;code&gt;compose_body&lt;/code&gt;.
Its default is unchanged - an absent &lt;code&gt;still_unknown&lt;/code&gt; still means &lt;code&gt;nothing&lt;/code&gt;,
because its schema has always said so - except that a trailer in the body now
takes precedence over that default.&lt;/p&gt;
&lt;p&gt;The skill now leads with the one-command form, and says to quote the heredoc
marker so backticks and &lt;code&gt;$&lt;/code&gt; in the prose arrive as written.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Areas in one line each and files that fold</title>
<id>https://toyz.github.io/cairns/23-areas-in-one-line-each-and-files-that-fold</id>
<link rel="alternate" href="https://toyz.github.io/cairns/23-areas-in-one-line-each-and-files-that-fold"/>
<updated>2026-09-24T00:00:00Z</updated>
<category term="spec"/>
<category term="site"/>
<summary>Two complaints from use, both about friction rather than correctness: adding an area was a chore, and an entry with fifteen files put a wall of paths between its title and its first sentence.</summary>
<content type="html">&lt;p&gt;Two complaints from use, both about friction rather than correctness: adding an
area was a chore, and an entry with fifteen files put a wall of paths between
its title and its first sentence.&lt;/p&gt;
&lt;h2 id=&quot;area-as-one-table&quot;&gt;&lt;code&gt;[area]&lt;/code&gt; as one table&lt;/h2&gt;
&lt;p&gt;An area is a name and a phrase, and &lt;code&gt;[[area]]&lt;/code&gt; spent three lines and two keys
saying so. &lt;code&gt;cairns.toml&lt;/code&gt; now also takes a single table:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[area]
spec = &quot;the format itself - entries, config, log.json, publishing&quot;
core = &quot;cairns-core: parsing, validation, the canonical document&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;[[area]]&lt;/code&gt; list is still read and means the same thing. It is deliberately
documented as the &lt;em&gt;long&lt;/em&gt; form rather than an old one: each area there is its own
table, so a key added to areas later has somewhere to go, and the short form
only has room for &lt;code&gt;about&lt;/code&gt;. TOML refuses a file holding both, so there is nothing
to reconcile.&lt;/p&gt;
&lt;p&gt;Both forms go through one hand-written visitor, &lt;code&gt;areas&lt;/code&gt; in &lt;code&gt;config.rs&lt;/code&gt;, rather
than an untagged enum - an untagged enum reports &quot;data did not match any
variant&quot; and throws away TOML&#39;s own message about what was actually wrong.&lt;/p&gt;
&lt;p&gt;The order matters, since it is the presentation order in the filters, the skill
and the index. I expected to need the &lt;code&gt;toml&lt;/code&gt; crate&#39;s &lt;code&gt;preserve_order&lt;/code&gt; feature
for that; I did not. &lt;code&gt;toml&lt;/code&gt; 0.8 hands a visitor the keys in document order
regardless - the feature only changes the order of its own &lt;code&gt;Table&lt;/code&gt; type.
&lt;code&gt;an_area_table_reads_in_the_order_written&lt;/code&gt; holds it, with areas declared
non-alphabetically.&lt;/p&gt;
&lt;p&gt;A name declared twice is now an error in either form. The table form gets that
from TOML for free; the list form never checked, and now &lt;code&gt;validate&lt;/code&gt; does.&lt;/p&gt;
&lt;p&gt;The cheaper half of &quot;adding an area is annoying&quot; was the error message. &lt;code&gt;cairns new --area nope&lt;/code&gt; now ends with the line to paste:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;to add it, write this under [area] in cairns.toml:
    nope = &quot;what belongs here&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It says &lt;code&gt;[area]&lt;/code&gt; even to a config written in the long form, because the parsed
config does not remember which form it came from. Not worth a flag.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cairns init&lt;/code&gt; writes the short form, and this repo&#39;s own config uses it.&lt;/p&gt;
&lt;h2 id=&quot;files-grouped-and-folded&quot;&gt;Files, grouped and folded&lt;/h2&gt;
&lt;p&gt;The entry page printed &lt;code&gt;files&lt;/code&gt; as one wrapped row of full paths. Most of the
length was the same &lt;code&gt;crates/x/src/&lt;/code&gt; repeated. Now each directory is said once,
muted, with the names after it, and the full path is on hover. Past five files
(&lt;code&gt;FILES_SHOWN&lt;/code&gt; in &lt;code&gt;html.rs&lt;/code&gt;) the list is a &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; that starts closed,
labelled with its count - &quot;15 in 7 directories&quot; - and opens to one directory per
line. No script.&lt;/p&gt;
&lt;p&gt;A path with a trailing slash is a directory and is named by its last component
under its parent, so &lt;code&gt;docs/spec/&lt;/code&gt; shows as &lt;code&gt;spec/&lt;/code&gt; under &lt;code&gt;docs/&lt;/code&gt;, not as an
empty group under itself.&lt;/p&gt;
&lt;p&gt;Checked with the render loop from &lt;a href=&quot;https://toyz.github.io/cairns/13-working-blind-and-the-render-loop-i-should-have-had-first/&quot; title=&quot;Working blind, and the render loop I should have had first&quot;&gt;13&lt;/a&gt;, against a copy of this log with
entry 22 given fifteen files, open and closed. The first screenshot showed the
filename inside a code chip and the directory outside it, which read as two
unrelated things; the chip is gone inside &lt;code&gt;.files&lt;/code&gt;. Tests:
&lt;code&gt;an_entrys_files_say_each_directory_once&lt;/code&gt;,
&lt;code&gt;a_long_list_of_files_folds_behind_its_count&lt;/code&gt;.&lt;/p&gt;
</content>
</entry>
<entry>
<title>A config section the binary had never heard of</title>
<id>https://toyz.github.io/cairns/22-a-config-section-the-binary-had-never-heard-of</id>
<link rel="alternate" href="https://toyz.github.io/cairns/22-a-config-section-the-binary-had-never-heard-of"/>
<updated>2026-09-21T00:00:00Z</updated>
<category term="core"/>
<category term="spec"/>
<summary>[[link]] from [[21]] worked here and did nothing on someone else&#39;s machine, with no error either way.</summary>
<content type="html">&lt;p&gt;&lt;code&gt;[[link]]&lt;/code&gt; from &lt;a href=&quot;https://toyz.github.io/cairns/21-links-the-generated-nav-could-never-know-about/&quot; title=&quot;Links the generated nav could never know about&quot;&gt;21&lt;/a&gt; worked here and did nothing on someone else&#39;s machine,
with no error either way. Two causes, and only one of them is interesting.&lt;/p&gt;
&lt;p&gt;The dull one: a Homebrew tap is a git clone, and it is as old as the last
&lt;code&gt;brew update&lt;/code&gt;. &lt;code&gt;brew upgrade cairns&lt;/code&gt; against a stale tap reinstalls the version
the tap knows about. Mine did exactly that while I was reproducing the
report - I installed &quot;0.5.0&quot; and got 0.4.1.&lt;/p&gt;
&lt;h2 id=&quot;the-one-that-matters&quot;&gt;The one that matters&lt;/h2&gt;
&lt;p&gt;A cairns that predates &lt;code&gt;[[link]]&lt;/code&gt; reads a config containing it, ignores the
section entirely, and prints &lt;code&gt;ok&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns --version
cairns 0.4.1
$ cairns check
ok
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is serde&#39;s default: unknown fields are skipped. It is the right default
for an entry&#39;s front matter, where &lt;a href=&quot;https://toyz.github.io/cairns/1-the-format-is-the-asset-so-the-spec-is-written-down-first/&quot; title=&quot;The format is the asset, so the spec is written down first&quot;&gt;1&lt;/a&gt; deliberately passes unknown keys
through so a project can carry its own metadata. It is the wrong default for
configuration, because configuration is instructions - a key the program does
not recognise is an instruction it is not following, and silence is the worst
possible answer.&lt;/p&gt;
&lt;p&gt;Every config struct now refuses what it does not recognise:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cairns.toml: TOML parse error at line 59, column 3
unknown field `lnik`, expected one of `spec_version`, `project`, `paths`,
`site`, `index`, `check`, `docs`, `area`, `publish`, `link`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;which catches a typo and, from here on, catches a config written for a newer
cairns than the one running. It cannot fix 0.4.1 - that binary will go on
ignoring things quietly - so the first symptom of being too old is still the
one that was reported. From v0.5.1 the failure is loud.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;spec_version&lt;/code&gt; was supposed to cover this and did not, because the rule says
additive changes do not bump it. That rule is right for the entry format, where
a new optional field genuinely does no harm to an old reader. For config it is
wrong, and the fix is the one above rather than a version bump on every added
section.&lt;/p&gt;
&lt;h2 id=&quot;serve-prints-its-version-now&quot;&gt;Serve prints its version now&lt;/h2&gt;
&lt;p&gt;Twice today I have been confused by a &lt;code&gt;cairns serve&lt;/code&gt; still running the binary
it was started with. It says which version it is at startup.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Links the generated nav could never know about</title>
<id>https://toyz.github.io/cairns/21-links-the-generated-nav-could-never-know-about</id>
<link rel="alternate" href="https://toyz.github.io/cairns/21-links-the-generated-nav-could-never-know-about"/>
<updated>2026-09-21T00:00:00Z</updated>
<category term="site"/>
<category term="spec"/>
<summary>The rail&#39;s navigation was whatever cairns had made: entries, the reference, the open questions, the About page, the feed.</summary>
<content type="html">&lt;p&gt;The rail&#39;s navigation was whatever cairns had made: entries, the reference, the
open questions, the About page, the feed. A project site needs to point
somewhere else too - the repository, the releases, a chat - and there was no way
to say so.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[[link]]
label = &quot;Repository&quot;
url   = &quot;https://github.com/Toyz/cairns&quot;
icon  = &quot;github&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They sit in their own group below the generated nav rather than mixed into it,
because one set is pages this tool made and the other is everywhere else, and a
reader can tell at a glance which is which.&lt;/p&gt;
&lt;h2 id=&quot;ten-icons-inline&quot;&gt;Ten icons, inline&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;github&lt;/code&gt;, &lt;code&gt;globe&lt;/code&gt;, &lt;code&gt;book&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;download&lt;/code&gt;, &lt;code&gt;rss&lt;/code&gt;, &lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;mail&lt;/code&gt;, &lt;code&gt;star&lt;/code&gt;,
&lt;code&gt;link&lt;/code&gt;, with the obvious aliases - &lt;code&gt;docs&lt;/code&gt; for &lt;code&gt;book&lt;/code&gt;, &lt;code&gt;feed&lt;/code&gt; for &lt;code&gt;rss&lt;/code&gt;,
&lt;code&gt;discord&lt;/code&gt; for &lt;code&gt;chat&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;They are inline SVG, for the same reason the page has no webfonts: an icon that
needs a request is missing for the first second, and missing forever behind a
proxy. Ten of them cost less than one request. The GitHub mark is the Octicons
path, which is MIT licensed; the rest are drawn as plain geometry.&lt;/p&gt;
&lt;p&gt;An unknown name renders no icon at all. That is right at build time - a link
without a glyph still works, and a typo should not stop a site from building -
but it is wrong to stay quiet about, so &lt;code&gt;check&lt;/code&gt; reports it with the list of
what is available:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cairns.toml: link &quot;Releases&quot; wants icon &quot;downlod&quot;, which is not one of:
github, globe, book, code, download, rss, chat, mail, star, link
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That pairing is the shape worth repeating: be forgiving in the renderer, strict
in &lt;code&gt;check&lt;/code&gt;. The site builds either way, and the person who made the typo finds
out.&lt;/p&gt;
</content>
</entry>
<entry>
<title>One plus-minus sign crashed the scanner</title>
<id>https://toyz.github.io/cairns/20-one-plus-minus-sign-crashed-the-scanner</id>
<link rel="alternate" href="https://toyz.github.io/cairns/20-one-plus-minus-sign-crashed-the-scanner"/>
<updated>2026-09-21T00:00:00Z</updated>
<category term="core"/>
<summary>thread &#39;main&#39; panicked at crates/cairns-core/src/entry.rs:490:32: start byte index 1 is not a char boundary; it is inside &#39;±&#39; (bytes 0..2 of string).</summary>
<content type="html">&lt;pre&gt;&lt;code&gt;thread &#39;main&#39; panicked at crates/cairns-core/src/entry.rs:490:32:
start byte index 1 is not a char boundary; it is inside &#39;±&#39; (bytes 0..2 of string)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reference scanner from &lt;a href=&quot;https://toyz.github.io/cairns/19-references-and-a-parser-that-reads-them-in-pieces/&quot; title=&quot;References, and a parser that reads them in pieces&quot;&gt;19&lt;/a&gt; walks each line looking for &lt;code&gt;[[&lt;/code&gt;, and where
a character is not one it advances with &lt;code&gt;at += 1&lt;/code&gt;. That is a byte, not a
character. &lt;code&gt;±&lt;/code&gt; is two bytes, so the next &lt;code&gt;line[at..]&lt;/code&gt; started inside it and
Rust refused - correctly, and fatally.&lt;/p&gt;
&lt;p&gt;It steps by &lt;code&gt;ch.len_utf8()&lt;/code&gt; now. Hellbender&#39;s log contains eight &lt;code&gt;±&lt;/code&gt;, amber&#39;s
contains eleven em dashes, and both were enough; hellbender&#39;s is the log that
found it.&lt;/p&gt;
&lt;h2 id=&quot;what-the-tests-missed-and-why&quot;&gt;What the tests missed and why&lt;/h2&gt;
&lt;p&gt;There were six tests on this scanner, covering references, labels, fenced
code, inline code and dangling numbers. Every one of them was written in
ASCII, because I wrote them, and I was thinking about the syntax rather than
about the text it sits in. A worklog is prose about measurements - &lt;code&gt;±&lt;/code&gt;, &lt;code&gt;—&lt;/code&gt;,
&lt;code&gt;µs&lt;/code&gt;, &lt;code&gt;°&lt;/code&gt; - and the one thing the scanner is guaranteed to meet is a character
I did not type into a test.&lt;/p&gt;
&lt;p&gt;The new tests run the scanner over &lt;code&gt;±&lt;/code&gt;, an em dash, an emoji, &lt;code&gt;±[[3]]±&lt;/code&gt;, and a
fence containing &lt;code&gt;±&lt;/code&gt;, and assert the surrounding text survives the rewrite
intact rather than merely that nothing panicked.&lt;/p&gt;
&lt;p&gt;It is also worth noticing what caught it: not &lt;code&gt;check&lt;/code&gt;, not the tests, not the
build. Someone ran it on a real log. That is the fourth time in two days that
the thing which found the fault was the tool meeting real content rather than
anything I ran against content I had invented.&lt;/p&gt;
&lt;h2 id=&quot;the-rest-of-the-codebase-checked&quot;&gt;The rest of the codebase, checked&lt;/h2&gt;
&lt;p&gt;The same habit of mind wrote the front matter splitter, the slug derivation,
the summary extractor and the trailer parser, so all of them were read again.
They are safe, and for a reason worth recording rather than by luck:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;every one of them slices at an index found by searching for an &lt;strong&gt;ASCII&lt;/strong&gt;
delimiter - &lt;code&gt;\n&lt;/code&gt;, &lt;code&gt;---&lt;/code&gt;, &lt;code&gt;. &lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;, &lt;code&gt;**Still unknown:**&lt;/code&gt; - and a byte
index found that way is always on a character boundary&lt;/li&gt;
&lt;li&gt;&lt;code&gt;slugify&lt;/code&gt; truncates its own output, which is ASCII by construction: it emits
only &lt;code&gt;a-z&lt;/code&gt;, &lt;code&gt;0-9&lt;/code&gt; and &lt;code&gt;-&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the one other &lt;code&gt;at += 1&lt;/code&gt; in the tree walks a &lt;code&gt;Vec&amp;lt;Event&amp;gt;&lt;/code&gt;, not a string&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the scanner was the only place advancing a byte at a time through text it
had not built. That is the distinction to watch for: searching for ASCII and
slicing there is fine, and stepping through arbitrary text is not.&lt;/p&gt;
</content>
</entry>
<entry>
<title>References, and a parser that reads them in pieces</title>
<id>https://toyz.github.io/cairns/19-references-and-a-parser-that-reads-them-in-pieces</id>
<link rel="alternate" href="https://toyz.github.io/cairns/19-references-and-a-parser-that-reads-them-in-pieces"/>
<updated>2026-09-21T00:00:00Z</updated>
<category term="spec"/>
<category term="core"/>
<category term="site"/>
<summary>An entry could point at another only by writing out its filename:.</summary>
<content type="html">&lt;p&gt;An entry could point at another only by writing out its filename:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;[12](0012-three-css-edits-three-wrong-anchors-and-half-a-stylesheet-gone.md)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;which you have to look up, can mistype, and which breaks if the slug ever
changes. &lt;code&gt;[[12]]&lt;/code&gt; now does it: a link to entry 12 labelled with the number and
carrying its title, with &lt;code&gt;[[12|in other words]]&lt;/code&gt; for your own wording. &lt;code&gt;check&lt;/code&gt;
rejects a reference to an entry that does not exist, which is the first
link-checking this tool has done and answers part of what &lt;a href=&quot;https://toyz.github.io/cairns/11-the-log-s-own-links-were-dead-on-its-own-site/&quot; title=&quot;The log&amp;#39;s own links were dead on its own site&quot;&gt;11&lt;/a&gt; left open.&lt;/p&gt;
&lt;p&gt;The trade is worth stating plainly: &lt;code&gt;[[12]]&lt;/code&gt; is literal text on GitHub, where
the filename form renders. Both work, so the choice is the author&#39;s - a
reference reads better and cannot rot, a file link survives outside this tool.
This log&#39;s six existing cross-links were converted.&lt;/p&gt;
&lt;h2 id=&quot;the-parser-hands-it-over-in-pieces&quot;&gt;The parser hands it over in pieces&lt;/h2&gt;
&lt;p&gt;The first attempt did the expansion on parsed events, which is where the
existing link rewriting happens, and produced nothing at all. A markdown parser
reads &lt;code&gt;[[12]]&lt;/code&gt; as nested bracket tokens - an unresolved shortcut reference
inside another pair of brackets - so the text arrives as several runs and &lt;code&gt;[[&lt;/code&gt;
is never present in one of them to match on. The compiler said only that the
function was never called.&lt;/p&gt;
&lt;p&gt;So it happens on the source instead, before parsing, and emits
&lt;code&gt;[12](cairns:12 &quot;title&quot;)&lt;/code&gt;. The scheme is resolved by the same function that
resolves every other link, so a reference and a written-out link reach the same
place by the same code rather than by two implementations that agree today.&lt;/p&gt;
&lt;h2 id=&quot;code-has-to-be-left-alone&quot;&gt;Code has to be left alone&lt;/h2&gt;
&lt;p&gt;The spec pages show &lt;code&gt;[[area]]&lt;/code&gt; and &lt;code&gt;[[publish]]&lt;/code&gt; in fenced TOML, and the page
documenting this syntax has to be able to print &lt;code&gt;[[12]]&lt;/code&gt; without linking it.
The scanner tracks fences and inline backticks, and the same scanner serves
both the rewriter and &lt;code&gt;check&lt;/code&gt;, so what gets linked and what gets validated
cannot disagree.&lt;/p&gt;
&lt;p&gt;Requiring digits would have protected &lt;code&gt;[[area]]&lt;/code&gt; by accident. The code rules
make it a guarantee.&lt;/p&gt;
&lt;h2 id=&quot;two-things-that-were-wrong-about-themselves&quot;&gt;Two things that were wrong about themselves&lt;/h2&gt;
&lt;p&gt;A test asserting that inline &lt;code&gt;`[[1]]`&lt;/code&gt; produces no link counted two and
failed. The second was the pager&#39;s &quot;Previous&quot; link, which points at entry 1
because entry 1 &lt;em&gt;is&lt;/em&gt; the previous entry. The code was right and the assertion
was too broad; it counts the reference form specifically now.&lt;/p&gt;
&lt;p&gt;And the skill did not pick up the new section on the first &lt;code&gt;init&lt;/code&gt;, because the
template is embedded with &lt;code&gt;include_str!&lt;/code&gt; and I ran the old binary. The template
had changed, the file on disk was right, and the generated output was stale -
which looks exactly like an edit that failed, and was not.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The tap is the repo</title>
<id>https://toyz.github.io/cairns/18-the-tap-is-the-repo</id>
<link rel="alternate" href="https://toyz.github.io/cairns/18-the-tap-is-the-repo"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="build"/>
<summary>A Homebrew tap does not need a repository of its own.</summary>
<content type="html">&lt;p&gt;A Homebrew tap does not need a repository of its own. &lt;code&gt;brew tap&lt;/code&gt; takes an
explicit URL, and any repo with a &lt;code&gt;Formula/&lt;/code&gt; directory in it is a tap:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;brew tap Toyz/cairns https://github.com/Toyz/cairns
brew install cairns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The cost is that one command instead of &lt;code&gt;brew install Toyz/tap/cairns&lt;/code&gt; - the
short form resolves &lt;code&gt;Toyz/cairns&lt;/code&gt; to &lt;code&gt;Toyz/homebrew-cairns&lt;/code&gt;, which is why a
dedicated tap is named that way. The gain is worth more than the keystrokes:
the release workflow updates the formula in the repo it is already checked out
in, with the token it already has. A separate tap needs a personal access token
with write access to another repository, kept in a secret, which is a thing to
create, store and eventually rotate.&lt;/p&gt;
&lt;p&gt;Verified rather than assumed, because Homebrew 7 made two of my assumptions
wrong on the way. &lt;code&gt;brew audit &amp;lt;path&amp;gt;&lt;/code&gt; is disabled, and so is installing a
formula from a path - &quot;Homebrew requires formulae to be in a tap&quot;. So the test
was a real tap pointed at this working copy, a real &lt;code&gt;brew install&lt;/code&gt;, and running
the binary it put on &lt;code&gt;PATH&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew tap Toyz/cairns &quot;$(pwd)&quot;
$ brew install Toyz/cairns/cairns
$ cairns --version
cairns 0.3.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also settled a detail I would otherwise have guessed at: each archive holds
one top-level directory, and Homebrew enters it before running &lt;code&gt;install&lt;/code&gt;, so
the binary is &lt;code&gt;cairns&lt;/code&gt; and not &lt;code&gt;cairns-v0.3.0-aarch64-apple-darwin/cairns&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-formula-is-generated-not-edited&quot;&gt;The formula is generated, not edited&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;scripts/update-formula.sh&lt;/code&gt; writes the whole file from a tag and that release&#39;s
&lt;code&gt;SHA256SUMS&lt;/code&gt;. Four values change per release - a version and three digests -
and a formula patched line by line drifts the moment a line moves. Today has
supplied five separate demonstrations of that, so this one regenerates.&lt;/p&gt;
&lt;p&gt;The release workflow runs it after the binaries are built and commits the
result to &lt;code&gt;main&lt;/code&gt;. A tag now updates the binaries, the checksums and the formula
together, which is the only way the three stay in agreement.&lt;/p&gt;
</content>
</entry>
<entry>
<title>An entry that says nothing about what it does not know</title>
<id>https://toyz.github.io/cairns/17-an-entry-that-says-nothing-about-what-it-does-not-know</id>
<link rel="alternate" href="https://toyz.github.io/cairns/17-an-entry-that-says-nothing-about-what-it-does-not-know"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="spec"/>
<category term="core"/>
<category term="cli"/>
<summary>Hellbender&#39;s entry 60 asks whether check should refuse an entry with no **Still unknown:** line at all, rather than accepting a log that quietly stops collecting.</summary>
<content type="html">&lt;p&gt;Hellbender&#39;s entry 60 asks whether &lt;code&gt;check&lt;/code&gt; should refuse an entry with no
&lt;code&gt;**Still unknown:**&lt;/code&gt; line at all, rather than accepting a log that quietly
stops collecting. It should, and the evidence in that entry settles it:
twenty-six consecutive entries had dropped the line, &lt;code&gt;cairns open&lt;/code&gt; had been
reporting the project&#39;s state as of entry 33, and nothing complained for
months, because a missing convention is not a broken one.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;check&lt;/code&gt; now reports two faults it used to pass over:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;worklog/0034-x.md: has no `**Still unknown:**` line - write `nothing` to close it out
worklog/0035-y.md: `**Still unknown:**` is empty - write `nothing` to close it out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The blank one matters as much as the missing one, and it was the easier of the
two to get: &lt;code&gt;cairns new&lt;/code&gt; writes the trailer into every entry it creates with
nothing after it. An entry nobody filled in and an entry that deliberately
closed out read identically to the collector, and only one of them meant it.&lt;/p&gt;
&lt;p&gt;So the trailer has four states rather than two - missing, blank, closed,
open - and only the last two are things a person decided.&lt;/p&gt;
&lt;h2 id=&quot;adoption-does-not-fail-on-history&quot;&gt;Adoption does not fail on history&lt;/h2&gt;
&lt;p&gt;Turning this on would have failed hellbender before it was fixed, and fails
amber now: 192 migrated entries, none with a trailer, because the format they
came from had no such convention.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;init&lt;/code&gt; writes the relaxation itself, the same way it freezes slugs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns init
192 entries have no `**Still unknown:**` line - check relaxed to optional in cairns.toml
$ cairns check
ok
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with a comment in &lt;code&gt;cairns.toml&lt;/code&gt; saying to set it back once they carry one. The
principle is the one adoption has followed throughout: preserve what is there,
hold what is written from here on to the better standard. A tool that makes a
project fix its history before it can be used is a tool nobody adopts.&lt;/p&gt;
</content>
</entry>
<entry>
<title>A field carried all the way through and shown nowhere</title>
<id>https://toyz.github.io/cairns/16-a-field-carried-all-the-way-through-and-shown-nowhere</id>
<link rel="alternate" href="https://toyz.github.io/cairns/16-a-field-carried-all-the-way-through-and-shown-nowhere"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="site"/>
<category term="spec"/>
<summary>files is in the spec as &quot;paths the entry is about&quot;.</summary>
<content type="html">&lt;p&gt;&lt;code&gt;files&lt;/code&gt; is in the spec as &quot;paths the entry is about&quot;. &lt;code&gt;cairns new --files&lt;/code&gt;
writes it, the parser reads it, &lt;code&gt;log.json&lt;/code&gt; exports it, and every one of this
log&#39;s entries has one. The site rendered it nowhere at all.&lt;/p&gt;
&lt;p&gt;It had passed through the entire pipeline without ever arriving anywhere a
reader could see it, which is why nothing caught it: there is no test for
&quot;appears on the page&quot;, and the field was present at every point anyone checked.&lt;/p&gt;
&lt;p&gt;It sits under the dateline now, each path linked into the repository through
the same resolution the prose uses - so an entry about a bug in &lt;code&gt;html.rs&lt;/code&gt; is
one click from &lt;code&gt;html.rs&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The general shape is worth naming, because this project has hit it twice today:
a field can be parsed, validated, exported and still be dead, and none of
parsing, validating or exporting will tell you. The other was the traffic-light
status badge, which rendered but looked like a different website. Both needed
somebody to look at the page.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Two install instructions that were not true</title>
<id>https://toyz.github.io/cairns/15-two-install-instructions-that-were-not-true</id>
<link rel="alternate" href="https://toyz.github.io/cairns/15-two-install-instructions-that-were-not-true"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="build"/>
<category term="adoption"/>
<summary>The README said:.</summary>
<content type="html">&lt;p&gt;The README said:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;cargo install cairns                    # from crates.io
brew install Toyz/tap/cairns            # macOS and Linux
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Neither works. &lt;code&gt;cairns&lt;/code&gt; is not on crates.io - the publish job is deliberately
gated behind a repository variable nobody has set - and &lt;code&gt;Toyz/homebrew-tap&lt;/code&gt;
does not exist. Both return 404. I wrote them while writing the release
plumbing, describing what the plumbing would make possible, and never came back
to check whether it had.&lt;/p&gt;
&lt;p&gt;That is the same failure as everything else today: stating a thing is so
because I arranged for it to be possible, rather than because I watched it
happen. A false install line is worse than most, because the person who finds
out is a stranger who wanted to try the tool and now thinks it is broken.&lt;/p&gt;
&lt;p&gt;The README now offers the two that were tested:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;curl -fsSL .../install.sh | sh
cargo install --git https://github.com/Toyz/cairns cairns
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second was run before it was written down. It works, and it reports its
version as &lt;code&gt;0.0.0-dev&lt;/code&gt;, because the repo never claims a release number - the
tag does, and CI applies it to the working tree only. The binary is right and
&lt;code&gt;--version&lt;/code&gt; is uninformative, which is a real cost of that design and is now
written next to the command rather than left to be discovered.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Hellbender&#39;s own docs tree crashed the renderer</title>
<id>https://toyz.github.io/cairns/14-hellbender-s-own-docs-tree-crashed-the-renderer</id>
<link rel="alternate" href="https://toyz.github.io/cairns/14-hellbender-s-own-docs-tree-crashed-the-renderer"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="site"/>
<category term="adoption"/>
<category term="core"/>
<summary>Enabling cairns on hellbender - the repo the format came from - took a cairns.toml and one init, which froze thirteen slugs and left the hand-written skill alone because it carries no marker.</summary>
<content type="html">&lt;p&gt;Enabling cairns on hellbender - the repo the format came from - took a
&lt;code&gt;cairns.toml&lt;/code&gt; and one &lt;code&gt;init&lt;/code&gt;, which froze thirteen slugs and left the
hand-written skill alone because it carries no marker. Then &lt;code&gt;build&lt;/code&gt; died:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thread &#39;main&#39; has overflowed its stack
fatal runtime error: stack overflow, aborting
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three faults, none of which this repo&#39;s own docs tree could have shown,
because this repo&#39;s docs tree is one directory with a README in it.&lt;/p&gt;
&lt;h2 id=&quot;the-docs-root-is-its-own-parent&quot;&gt;The docs root is its own parent&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;docs/README.md&lt;/code&gt; is the index &lt;em&gt;of&lt;/em&gt; &lt;code&gt;docs/&lt;/code&gt;, so its slug is the directory it
names - which at the root is the empty string. Its section is the directory
above it, which at the root is also the empty string. The tree renderer asked
for the folders inside a section, got a folder whose slug &lt;em&gt;was&lt;/em&gt; that section,
recursed into it, and asked the same question again.&lt;/p&gt;
&lt;p&gt;Two lines guard it now, and the regression test renders a tree with a root
README: a regression there is a crash, not a failed assertion.&lt;/p&gt;
&lt;p&gt;The test earned itself immediately. It caught a second fault I had already
convinced myself was fixed - &lt;code&gt;docs//index.html&lt;/code&gt;, the root README being given a
page of its own as well as being the index. The build &quot;worked&quot; and wrote 88
files; I counted the files and did not read them.&lt;/p&gt;
&lt;h2 id=&quot;the-rail-was-empty-on-the-one-repo-that-needed-it&quot;&gt;The rail was empty on the one repo that needed it&lt;/h2&gt;
&lt;p&gt;Folders were derived from index pages, so a directory without a README was not
a folder and its pages belonged to a section that did not exist. Hellbender has
four such directories - &lt;code&gt;formats&lt;/code&gt;, &lt;code&gt;engine&lt;/code&gt;, &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;port&lt;/code&gt; - and every page
in them. Its reference rail rendered a heading and nothing else.&lt;/p&gt;
&lt;p&gt;Folders come from the directories themselves now. One with its own page is a
link to it; one without is a label. Deriving structure from what a project
happens to have written is how you get a tree that is empty for the projects
with the most in them.&lt;/p&gt;
&lt;h2 id=&quot;worklog-7-to-20&quot;&gt;&lt;code&gt;worklog: 7 to 20&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;docs/port/plan.md&lt;/code&gt; cites a range. The spec said comma-separated numbers, so
&lt;code&gt;check&lt;/code&gt; rejected it, correctly and uselessly - a page established over a run of
entries is naturally written that way, and hellbender&#39;s was, years before this
tool existed. &lt;code&gt;7 to 20&lt;/code&gt; and &lt;code&gt;7-20&lt;/code&gt; both expand now, inclusive.&lt;/p&gt;
&lt;p&gt;The alternative was editing their page to match the parser. The parser was
wrong about what people write.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Working blind, and the render loop I should have had first</title>
<id>https://toyz.github.io/cairns/13-working-blind-and-the-render-loop-i-should-have-had-first</id>
<link rel="alternate" href="https://toyz.github.io/cairns/13-working-blind-and-the-render-loop-i-should-have-had-first"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="site"/>
<summary>Four rounds of &quot;still cramped&quot;, &quot;so fucking tacky&quot;, &quot;the page is too thin&quot;, before the accurate version of the complaint arrived: you&#39;re not even using a headless browser, you&#39;re throwing shit at a wall.</summary>
<content type="html">&lt;p&gt;Four rounds of &quot;still cramped&quot;, &quot;so fucking tacky&quot;, &quot;the page is too thin&quot;,
before the accurate version of the complaint arrived: &lt;em&gt;you&#39;re not even using a
headless browser, you&#39;re throwing shit at a wall.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That was exactly right. Every CSS change in this project had been written,
compiled, verified by grepping the rendered HTML for class names, and handed to
a person to look at. I was using the reader as the render loop. Everything I
called verification - the page returns 200, the tags close, the rule is in the
stylesheet - tests that the bytes are what I wrote, never that the result looks
like anything.&lt;/p&gt;
&lt;p&gt;It is also how &lt;a href=&quot;https://toyz.github.io/cairns/12-three-css-edits-three-wrong-anchors-and-half-a-stylesheet/&quot; title=&quot;Three CSS edits, three wrong anchors, and half a stylesheet gone&quot;&gt;12&lt;/a&gt;
went unnoticed. Half a stylesheet was missing and every check I had still
passed, because a stylesheet with its middle deleted is a valid stylesheet.&lt;/p&gt;
&lt;h2 id=&quot;the-loop&quot;&gt;The loop&lt;/h2&gt;
&lt;p&gt;Headless Edge is already on this machine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;&quot;$EDGE&quot; --headless --disable-gpu --hide-scrollbars \
  --window-size=1440,1700 --virtual-time-budget=2500 \
  --screenshot=/tmp/shot.png http://127.0.0.1:8899/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three seconds a shot, against &lt;code&gt;cairns serve&lt;/code&gt;. The first screenshot answered the
question four rounds of guessing had not: the page had no structure at all. A
flat column on a flat ground, a small masthead lost at the top, controls
floating unattached above an undifferentiated list. Not &quot;cramped&quot; - &lt;em&gt;empty&lt;/em&gt;, in
the way a page with nothing holding it together is empty.&lt;/p&gt;
&lt;h2 id=&quot;what-it-became&quot;&gt;What it became&lt;/h2&gt;
&lt;p&gt;A rail and a column. The rail is sticky and carries the identity, the
navigation with the current page marked, and - where they belong - the area
filters, as a facet list with counts rather than a stack of pills that looked
like form fields. The column is the same width on every page.&lt;/p&gt;
&lt;p&gt;The reference section gets the same treatment: the docs tree in the rail,
folders naming themselves and indenting what is inside them, the current page
marked. A docs section whose only navigation is an index you have to go back to
is a docs section nobody navigates.&lt;/p&gt;
&lt;p&gt;Entry rows became one target rather than a title-sized one, with the number
legible in a left gutter instead of buried in the meta line. Search stopped
being an outlined box with a segmented control bolted beside it and became a
filled field with an icon, and two words you can click.&lt;/p&gt;
&lt;p&gt;None of those were ideas I could have had from the source. Each one was
obvious within a second of looking at a picture.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Three CSS edits, three wrong anchors, and half a stylesheet gone</title>
<id>https://toyz.github.io/cairns/12-three-css-edits-three-wrong-anchors-and-half-a-stylesheet</id>
<link rel="alternate" href="https://toyz.github.io/cairns/12-three-css-edits-three-wrong-anchors-and-half-a-stylesheet"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="site"/>
<summary>The layout work was a run of small fixes - one page width everywhere, a simpler entry list, one hover effect instead of three, a one-line footer, the contents list moved out of the flow.</summary>
<content type="html">&lt;p&gt;The layout work was a run of small fixes - one page width everywhere, a simpler
entry list, one hover effect instead of three, a one-line footer, the contents
list moved out of the flow. Each was correct. The way I was making them was not.&lt;/p&gt;
&lt;p&gt;I was editing by locating a marker string and splicing around it. That failed
three times in a row, in two different ways.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In Rust, &lt;code&gt;cargo fmt&lt;/code&gt; moved the target.&lt;/strong&gt; A block I had read as one line came
back as six, so the marker no longer matched and the edit silently did nothing -
&lt;code&gt;log.rs&lt;/code&gt;, &lt;code&gt;entry.rs&lt;/code&gt; and &lt;code&gt;serve.rs&lt;/code&gt; all went through a compile error that only
said a later name was missing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In CSS, I assumed document order and was wrong.&lt;/strong&gt; The edit took everything
between &lt;code&gt;.toc {&lt;/code&gt; and a marker far below it. &lt;code&gt;.toc&lt;/code&gt; had been inserted near the
top of the file, so the splice removed 194 lines in between: the masthead, the
nav, the chips, the sort control, the status line and the whole entry list. The
page still rendered. It rendered with nav links run together, a default blue
link where the title should be and bare text where the pills should be, which is
what a stylesheet missing its middle looks like.&lt;/p&gt;
&lt;p&gt;Nothing caught it. It compiled, the tests passed, &lt;code&gt;check&lt;/code&gt; passed, every page
returned 200 and every page validated - because a stylesheet that is missing
half its rules is still a valid stylesheet. It took a person looking at the
screen, which is the third time in this session that has been the thing that
found the bug.&lt;/p&gt;
&lt;p&gt;It happened a fourth time before the entry was finished. The &lt;code&gt;resolves&lt;/code&gt;
validation in &lt;code&gt;problems()&lt;/code&gt; was written, missed its anchor, and never landed -
and in the meantime I said in conversation that &lt;code&gt;check&lt;/code&gt; rejected an invalid
&lt;code&gt;resolves&lt;/code&gt;, which it did not. The code compiled, the tests passed, and the
claim was simply wrong. The last edit was made by line position after reading
the file, and the validation now has tests of its own.&lt;/p&gt;
&lt;p&gt;The stylesheet was rewritten whole rather than patched back, and each section
asserted present afterwards.&lt;/p&gt;
&lt;p&gt;The lesson is about method, not CSS. An edit that deletes a region has to verify
the region first, and a marker-based splice does not - it silently does the
wrong thing when the file is not shaped the way it was last read. Replacing an
exact known string is safe because a miss is a no-op; splicing between two
indices is not, because a miss is a deletion.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The log&#39;s own links were dead on its own site</title>
<id>https://toyz.github.io/cairns/11-the-log-s-own-links-were-dead-on-its-own-site</id>
<link rel="alternate" href="https://toyz.github.io/cairns/11-the-log-s-own-links-were-dead-on-its-own-site"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="site"/>
<summary>The skill tells you to correct an earlier entry by linking to it:.</summary>
<content type="html">&lt;p&gt;The skill tells you to correct an earlier entry by linking to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;[[12]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is right in the repo and right on GitHub. On the generated site it is a
404, because there is no &lt;code&gt;.md&lt;/code&gt; file there - an entry is a directory named for
its number and its slug. Every cross-entry link in this log, which is the
convention the format exists to encourage, was broken on the thing built to
show the format off:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ curl -o /dev/null -w &#39;%{http_code}&#39; .../7-migrate/0004-the-write-path.md
404
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fix is translation rather than a new convention. A link whose filename
parses as &lt;code&gt;NNNN-&lt;/code&gt; and matches an entry becomes that entry&#39;s page; anything else
relative is a file in the repository and points there. Both spellings stay
correct where they already were.&lt;/p&gt;
&lt;p&gt;Two things fell out of doing it properly. A relative link inside an entry is
relative to the &lt;em&gt;entries directory&lt;/em&gt;, so &lt;code&gt;../docs/spec/entry.md&lt;/code&gt; is
&lt;code&gt;docs/spec/entry.md&lt;/code&gt; at the repository root - the first attempt pasted the
&lt;code&gt;../&lt;/code&gt; into the URL and produced
&lt;code&gt;github.com/Toyz/cairns/blob/HEAD/../docs/spec/entry.md&lt;/code&gt;. And the feed carries
the same prose with no page to resolve a relative link against, so entry links
render absolute there and relative on the site.&lt;/p&gt;
&lt;h2 id=&quot;why-this-went-unnoticed&quot;&gt;Why this went unnoticed&lt;/h2&gt;
&lt;p&gt;Every page returned 200 and every page validated. The broken thing was a link
&lt;em&gt;inside&lt;/em&gt; rendered prose, which nothing checks - not &lt;code&gt;check&lt;/code&gt;, which reads front
matter, and not the HTML validation, which only asks whether tags close.&lt;/p&gt;
&lt;p&gt;A link checker over the rendered site would have caught it in a second, and
there is still no such check. That is the gap, rather than the bug.&lt;/p&gt;
</content>
</entry>
<entry>
<title>A question can be answered, not just a claim overturned</title>
<id>https://toyz.github.io/cairns/10-a-question-can-be-answered-not-just-a-claim-overturned</id>
<link rel="alternate" href="https://toyz.github.io/cairns/10-a-question-can-be-answered-not-just-a-claim-overturned"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="spec"/>
<category term="core"/>
<category term="site"/>
<summary>Asked what happens when a later entry answers an earlier one&#39;s open question, and the answer was: nothing.</summary>
<content type="html">&lt;p&gt;Asked what happens when a later entry answers an earlier one&#39;s open question,
and the answer was: nothing. The question stayed on the open list forever. The
log could record that a claim had been overturned and could not record that a
question had been closed, which are different things and only one of them had a
field.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;resolves&lt;/code&gt; is that field.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
number: 7
resolves: 6
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is deliberately not &lt;code&gt;supersedes&lt;/code&gt;. An entry that answers a question another
entry left open has not shown that entry to be wrong - it closed something that
entry opened. Folding the two together would lose the distinction that makes
either of them worth recording at all.&lt;/p&gt;
&lt;p&gt;A question with a &lt;code&gt;resolves&lt;/code&gt; pointing at it leaves &lt;code&gt;open_questions&lt;/code&gt;, so that
list is what the project does &lt;em&gt;not yet&lt;/em&gt; know rather than everything it has ever
wondered. It stays on the entry that asked it, struck through, naming what
closed it, because the fact that it was once open is part of the record.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;check&lt;/code&gt; rejects a &lt;code&gt;resolves&lt;/code&gt; aimed at an entry that left no question open. That
is almost always a wrong number, and nothing else in the tool would notice it.&lt;/p&gt;
&lt;h2 id=&quot;the-one-real-case-backfilled&quot;&gt;The one real case, backfilled&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://toyz.github.io/cairns/6-the-log-reads-as-a-website-and-the-feed-is-not-the-whole-log/&quot; title=&quot;The log reads as a website, and the feed is not the whole log&quot;&gt;6&lt;/a&gt; ended
asking whether client-side search stays sensible as a log grows.
&lt;a href=&quot;https://toyz.github.io/cairns/7-migrate-and-the-backup-that-never-fired/&quot; title=&quot;Migrate, and the backup that never fired&quot;&gt;7&lt;/a&gt; measured it at 192 entries
and answered it. The field did not exist when 7 was written, so &lt;code&gt;resolves: 6&lt;/code&gt;
was added to it afterwards.&lt;/p&gt;
&lt;p&gt;Worth saying out loud, because the log is append-only: that is a metadata
addition to front matter, not an edit to anything 7 claims. The prose is
untouched. Had the correction been to a sentence rather than a field, the rule
would have required a new entry instead.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The viewer felt cheap, and the filters had never worked</title>
<id>https://toyz.github.io/cairns/9-the-viewer-felt-cheap-and-the-filters-had-never-worked</id>
<link rel="alternate" href="https://toyz.github.io/cairns/9-the-viewer-felt-cheap-and-the-filters-had-never-worked"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="site"/>
<category term="cli"/>
<summary>Three rounds of &quot;still cramped&quot; before the actual problem was named: it felt cheap.</summary>
<content type="html">&lt;p&gt;Three rounds of &quot;still cramped&quot; before the actual problem was named: it felt
cheap. That is a different complaint from a spacing one, and spacing is all I
had been adjusting. A page with no identity reads as a default HTML list
because that is exactly what it was.&lt;/p&gt;
&lt;h2 id=&quot;a-direction-rather-than-another-nudge&quot;&gt;A direction, rather than another nudge&lt;/h2&gt;
&lt;p&gt;A worklog is a lab notebook: numbered, dated, written in prose, read slowly. So
the page is set like one. Prose and titles are a serif; the chrome - nav, chips,
meta, code - is a sans; the entry number is large and quiet in the gutter and is
the one piece of furniture on every page. The ground is warm paper rather than
white, and the accent is a rust that marks the things a notebook marks: a
correction, the open question at the foot of an entry, the section you are in.&lt;/p&gt;
&lt;p&gt;No webfonts. The stacks resolve to something good without a request, because a
log that only looks right online is a log that does not look right.&lt;/p&gt;
&lt;p&gt;The measurements that changed, after two passes that did not:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;              first     second    now
base type     16px      17px      17px, prose in a serif
entry title   17px      19px      22px
summary       14px      16px      17px
index width   704px     768px     992px
reading width 704px     592px     704px, with the contents list beside it
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The width was the part I kept getting wrong in both directions. An index and a
reading column want different things, and giving them one number makes one of
them wrong. The index is wide and puts the date and areas in their own column
on the right; an entry page is a reading column with its contents list in the
margin.&lt;/p&gt;
&lt;h2 id=&quot;the-filters-had-never-worked-and-said-nothing&quot;&gt;The filters had never worked, and said nothing&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.entries li { display: flex; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The filter code sets &lt;code&gt;hidden&lt;/code&gt; on a row. The browser&#39;s own &lt;code&gt;[hidden] { display: none }&lt;/code&gt; is one class less specific than &lt;code&gt;.entries li&lt;/code&gt;, so every
hidden row kept its &lt;code&gt;display: flex&lt;/code&gt; and stayed on screen. Clicking an area chip
updated the button, updated the URL, updated the count in the status line, and
changed nothing visible.&lt;/p&gt;
&lt;p&gt;I introduced it in the same pass that made rows flex, which is worth saying
plainly: the redesign broke a feature that had worked, and no test, no build and
no console message registered it. It took a person clicking one.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;[hidden] { display: none !important; }&lt;/code&gt; sits above every display rule now, with
a comment saying why, because the next layout change would do it again.&lt;/p&gt;
&lt;h2 id=&quot;a-contents-list-and-pages-that-reload-themselves&quot;&gt;A contents list, and pages that reload themselves&lt;/h2&gt;
&lt;p&gt;Entries run to several &lt;code&gt;##&lt;/code&gt; sections - hellbender&#39;s 55 carry 143 between them -
and there was no way to move around inside one. Headings now get anchors, and
an entry with more than one section gets a contents list: sticky in the margin
on a wide screen, above the prose on a narrow one, with subsections nested.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cairns serve&lt;/code&gt; was already rebuilding when an entry changed, but the browser had
no way to find out, so it meant nothing without a manual refresh. Pages served
by it now poll a counter and reload when it moves. Polling rather than an event
stream, because the server is single-threaded on purpose and one held-open
stream is a server answering nothing else. The script is injected on the way out
and is never in what &lt;code&gt;build&lt;/code&gt; writes - checked by a test, since a production site
quietly polling a dead endpoint would be a nasty thing to ship.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Distribution, and a worklog a model can read</title>
<id>https://toyz.github.io/cairns/8-distribution-and-a-worklog-a-model-can-read</id>
<link rel="alternate" href="https://toyz.github.io/cairns/8-distribution-and-a-worklog-a-model-can-read"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="cli"/>
<category term="build"/>
<category term="skill"/>
<summary>Two things that both amount to the same question - who can get at this - and one correction to work done earlier in the day.</summary>
<content type="html">&lt;p&gt;Two things that both amount to the same question - who can get at this - and one
correction to work done earlier in the day.&lt;/p&gt;
&lt;h2 id=&quot;the-binary&quot;&gt;The binary&lt;/h2&gt;
&lt;p&gt;CI runs fmt, clippy with &lt;code&gt;-D warnings&lt;/code&gt;, the tests, and &lt;code&gt;cairns check&lt;/code&gt; against
this repo&#39;s own log, so the tool is held to the standard it holds everyone else
to. A second job builds on the declared MSRV, 1.88, which is what let-chains in
&lt;code&gt;config.rs&lt;/code&gt; cost. Tagging cuts binaries for five targets - macOS on both
architectures, Linux gnu and musl, Windows - with a &lt;code&gt;SHA256SUMS&lt;/code&gt; beside them,
and there is a Homebrew formula template in &lt;code&gt;packaging/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cargo publish --dry-run&lt;/code&gt; passes, and the packaged crates carry what they need:
the binary&#39;s &lt;code&gt;templates/SKILL.md&lt;/code&gt;, the site crate&#39;s CSS and JS.&lt;/p&gt;
&lt;h3 id=&quot;the-actions-were-years-out-of-date&quot;&gt;The actions were years out of date&lt;/h3&gt;
&lt;p&gt;Every workflow was written against the versions in my head, and every one was
several majors behind:&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;written&lt;/th&gt;&lt;th&gt;current&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;actions/checkout&lt;/code&gt;&lt;/td&gt;&lt;td&gt;v4&lt;/td&gt;&lt;td&gt;v7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;actions/upload-artifact&lt;/code&gt;&lt;/td&gt;&lt;td&gt;v4&lt;/td&gt;&lt;td&gt;v7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;actions/download-artifact&lt;/code&gt;&lt;/td&gt;&lt;td&gt;v4&lt;/td&gt;&lt;td&gt;v8&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;actions/upload-pages-artifact&lt;/code&gt;&lt;/td&gt;&lt;td&gt;v3&lt;/td&gt;&lt;td&gt;v5&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;actions/deploy-pages&lt;/code&gt;&lt;/td&gt;&lt;td&gt;v4&lt;/td&gt;&lt;td&gt;v5&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;softprops/action-gh-release&lt;/code&gt;&lt;/td&gt;&lt;td&gt;v2&lt;/td&gt;&lt;td&gt;v3&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Bumping a major is not the same as bumping a number, so each one&#39;s &lt;code&gt;action.yml&lt;/code&gt;
was read at the new tag to confirm the inputs still exist - &lt;code&gt;merge-multiple&lt;/code&gt;,
&lt;code&gt;path&lt;/code&gt;, &lt;code&gt;files&lt;/code&gt;, &lt;code&gt;generate_release_notes&lt;/code&gt;, the &lt;code&gt;page_url&lt;/code&gt; output. They all do.
The workflows themselves have not been run; they are eyeballed YAML until a
push proves otherwise.&lt;/p&gt;
&lt;h3 id=&quot;installing-without-a-toolchain&quot;&gt;Installing without a toolchain&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;install.sh&lt;/code&gt; picks the target from &lt;code&gt;uname&lt;/code&gt;, resolves the latest tag from the
API unless &lt;code&gt;CAIRNS_VERSION&lt;/code&gt; says otherwise, and - the part that matters in
anything piped to a shell - checks the download against the release&#39;s
&lt;code&gt;SHA256SUMS&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; unpacking it. On Linux it prefers the gnu build and falls
back to musl where there is no glibc.&lt;/p&gt;
&lt;p&gt;Tested against a fake release served locally, because a script nobody has run
is a script that does not work:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;good checksum      -&amp;gt; installed, `cairns --version` runs
tampered archive   -&amp;gt; &quot;checksum mismatch&quot;, exit 1, nothing installed
missing from sums  -&amp;gt; &quot;not listed in SHA256SUMS&quot;, exit 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-tag-is-the-version&quot;&gt;The tag is the version&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Cargo.toml&lt;/code&gt; says &lt;code&gt;0.0.0-dev&lt;/code&gt; and never says anything else. Pushing &lt;code&gt;v0.2.0&lt;/code&gt;
makes &lt;code&gt;scripts/version-from-tag.sh&lt;/code&gt; rewrite the workspace version in the
runner&#39;s working tree, and nothing is committed - so there is no file to
remember to bump, and no way for a tag and a manifest to disagree about what a
release is.&lt;/p&gt;
&lt;p&gt;The rewrite replaces the &lt;em&gt;exact&lt;/em&gt; current version string, which appears as the
workspace version and as the pin on each path dependency and nowhere else, so
&lt;code&gt;serde = { version = &quot;1&quot; }&lt;/code&gt; is untouched. &lt;code&gt;cargo update --workspace&lt;/code&gt; then moves
only the workspace members in the lockfile, which leaves &lt;code&gt;--locked&lt;/code&gt; meaning
what it meant. Checked locally by running it for &lt;code&gt;v1.2.3&lt;/code&gt;, building, and
confirming the binary says &lt;code&gt;cairns 1.2.3&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Publishing to crates.io stays off behind a repository variable. A tag that
cuts binaries is a small mistake to make; a tag that publishes a crate version
that can never be reused is not.&lt;/p&gt;
&lt;h2 id=&quot;mcp&quot;&gt;MCP&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;cairns mcp&lt;/code&gt; speaks JSON-RPC 2.0 over stdin and stdout. Read-only by default:
tools for listing, reading, searching, open questions and &lt;code&gt;check&lt;/code&gt;, and
resources at &lt;code&gt;worklog://entry/50&lt;/code&gt;, &lt;code&gt;worklog://open&lt;/code&gt;, &lt;code&gt;worklog://index&lt;/code&gt; and
&lt;code&gt;worklog://log.json&lt;/code&gt;. &lt;code&gt;--write&lt;/code&gt; adds one tool for writing an entry, and its
schema enumerates only the areas the project declares, so a model cannot invent
one.&lt;/p&gt;
&lt;p&gt;It is hand-rolled rather than taken from &lt;code&gt;rmcp&lt;/code&gt;. The surface used here is small
and synchronous and wants no async runtime; the cost is that protocol drift is
ours. One hedge against that: &lt;code&gt;initialize&lt;/code&gt; echoes back whatever
&lt;code&gt;protocolVersion&lt;/code&gt; the client asked for rather than insisting on a known one,
because nothing in this server&#39;s tools or resources has ever differed between
versions and refusing a newer client would break a host for no gain.&lt;/p&gt;
&lt;p&gt;Driven with a real client, writing through it produces an entry indistinguishable
from one the CLI wrote, &lt;code&gt;supersedes&lt;/code&gt; and all - and reading the entry it corrects
immediately says so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;worklog_read 47  -&amp;gt;  NOTE: something claimed here was corrected by entry 56.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reason this was half a day rather than a week is a decision from
&lt;a href=&quot;https://toyz.github.io/cairns/1-the-format-is-the-asset-so-the-spec-is-written-down-first/&quot; title=&quot;The format is the asset, so the spec is written down first&quot;&gt;1&lt;/a&gt;: the core
has no filesystem in its API and &lt;code&gt;log.json&lt;/code&gt; is already the canonical
serialisation, so the server is a transport over a document that existed, not
new logic.&lt;/p&gt;
&lt;p&gt;Worth being plain about where it does &lt;em&gt;not&lt;/em&gt; help. In a host with a shell it adds
nothing - the skill &lt;code&gt;init&lt;/code&gt; writes teaches the CLI, and every entry in this log
was written that way. MCP is for the host that has no terminal.&lt;/p&gt;
</content>
</entry>
<entry>
<title>Migrate, and the backup that never fired</title>
<id>https://toyz.github.io/cairns/7-migrate-and-the-backup-that-never-fired</id>
<link rel="alternate" href="https://toyz.github.io/cairns/7-migrate-and-the-backup-that-never-fired"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="cli"/>
<category term="adoption"/>
<summary>migrate splits a single-file worklog on its ##  headings, takes the number and title from each, demotes the sub-headings one level - the old format made the entry title an h2 and its sections h3, the new one makes the title the h1 - and writes one file per entry.</summary>
<content type="html">&lt;p&gt;&lt;code&gt;migrate&lt;/code&gt; splits a single-file worklog on its &lt;code&gt;## &lt;/code&gt; headings, takes the number
and title from each, demotes the sub-headings one level - the old format made
the entry title an &lt;code&gt;h2&lt;/code&gt; and its sections &lt;code&gt;h3&lt;/code&gt;, the new one makes the title the
&lt;code&gt;h1&lt;/code&gt; - and writes one file per entry.&lt;/p&gt;
&lt;p&gt;Amber&#39;s log is the real test: 9,727 lines, 192 entries, all numbered.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns migrate WORKLOG.md --date 2026-08-01
kept the original as .../WORKLOG.md.bak
192 entries -&amp;gt; worklog
192 entries -&amp;gt; WORKLOG.md
every entry is dated 2026-08-01 and filed under &quot;port&quot; - the old format
carried neither, so both want correcting by hand
4 entries have a &quot;not done&quot; section that should become a `**Still unknown:**`
trailer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fidelity checks exactly: 7,329 non-blank prose lines in the original outside its
headings, 7,329 across the 192 entries, and the two lists are identical once the
&lt;code&gt;###&lt;/code&gt; demotion is applied. Nothing was reflowed, reordered or dropped.&lt;/p&gt;
&lt;p&gt;What cannot be recovered is what the old format never held. Every entry gets one
date and one area, which is a lie of uniformity rather than a loss - but it is
visible, it is reported, and it is correctable by hand.&lt;/p&gt;
&lt;h2 id=&quot;the-backup-never-fired-and-the-original-was-destroyed&quot;&gt;The backup never fired, and the original was destroyed&lt;/h2&gt;
&lt;p&gt;The first run printed no backup line and left this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WORKLOG.md   9,727 lines  -&amp;gt;  202 lines
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The migration wrote 192 entries correctly and then &lt;code&gt;index&lt;/code&gt; regenerated
&lt;code&gt;WORKLOG.md&lt;/code&gt; over the top of the file it had just read. The guard meant to
prevent that was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;if from == root.join(&amp;amp;config.paths.index) {
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;from&lt;/code&gt; is the path as typed - &lt;code&gt;WORKLOG.md&lt;/code&gt;, relative. &lt;code&gt;root.join(...)&lt;/code&gt; is
absolute. The comparison is &lt;em&gt;always&lt;/em&gt; false, so the backup branch was dead code
and nothing said so. In the test clone the original came back out of git. In a
repo where the log had uncommitted edits, it would not have.&lt;/p&gt;
&lt;p&gt;This is the same shape as the &lt;code&gt;init&lt;/code&gt; bug in &lt;a href=&quot;https://toyz.github.io/cairns/4-the-write-path-and-an-init-that-nearly-ate-hellbender-s/&quot; title=&quot;The write path, and an init that nearly ate hellbender&amp;#39;s skill&quot;&gt;4&lt;/a&gt;:
a command whose job is to help a project adopt the tool, destroying the thing it
was adopting, and saying nothing. Twice now, which makes it a pattern rather than
an accident - both times the destructive path was the one no test covered,
because both were about a file the tool did not create.&lt;/p&gt;
&lt;p&gt;The fix resolves both paths before anything is written, refuses if a &lt;code&gt;.bak&lt;/code&gt; is
already there, and treats a failed copy as a reason to stop rather than a
warning to print on the way past.&lt;/p&gt;
&lt;h2 id=&quot;what-192-entries-cost&quot;&gt;What 192 entries cost&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;build       0.095s
site        2.5 MB, 199 files
index.html  63 KB raw / 17 KB gzipped
search.json 468 KB raw / 159 KB gzipped
log.json    582 KB raw / 193 KB gzipped
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That answers most of what &lt;a href=&quot;https://toyz.github.io/cairns/6-the-log-reads-as-a-website-and-the-feed-is-not-the-whole-log/&quot; title=&quot;The log reads as a website, and the feed is not the whole log&quot;&gt;6&lt;/a&gt;
left open about client-side search. Fetched once, lazily, over a host that
serves gzip, 159 KB is unremarkable. Linear growth puts a 500-entry log around
410 KB gzipped, which is where a real index rather than a string scan starts to
be worth it - so the answer is &quot;fine, and the number to watch is 500&quot;.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The log reads as a website, and the feed is not the whole log</title>
<id>https://toyz.github.io/cairns/6-the-log-reads-as-a-website-and-the-feed-is-not-the-whole-log</id>
<link rel="alternate" href="https://toyz.github.io/cairns/6-the-log-reads-as-a-website-and-the-feed-is-not-the-whole-log"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="site"/>
<category term="publish"/>
<category term="cli"/>
<summary>cairns build renders the payload: an entry page each with prev/next and clean URLs, an index with area chips and a search box, an open-questions page, an Atom feed, a search index, and the log.json all of it was built from.</summary>
<content type="html">&lt;p&gt;&lt;code&gt;cairns build&lt;/code&gt; renders the payload: an entry page each with prev/next and clean
URLs, an index with area chips and a search box, an open-questions page, an
Atom feed, a search index, and the &lt;code&gt;log.json&lt;/code&gt; all of it was built from. The
renderer consumes that document and nothing else - it never opens an entry
file - which is what keeps a publish target and a local build the same code
path.&lt;/p&gt;
&lt;p&gt;Against hellbender&#39;s 55 entries: 57 pages, all of which parse with no unclosed
or stray tags, 74 code blocks and 143 sub-headings rendered, 33 open questions
collected, 1.2 MB total.&lt;/p&gt;
&lt;h2 id=&quot;the-correction-notice-works-once-there-is-a-correction-to&quot;&gt;The correction notice works, once there is a correction to show&lt;/h2&gt;
&lt;p&gt;Hellbender has no &lt;code&gt;supersedes&lt;/code&gt; edges, so the feature had nothing to render.
Backfilling the one entry 2 identified - &lt;code&gt;supersedes: 6&lt;/code&gt; on entry 50, which
overturns what entry 6 said about &lt;code&gt;KREASH.MIX&lt;/code&gt; - produced both halves:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;entry 6   Revisited later. Something claimed here was corrected by
          KREASH.MIX is the end of a table.
entry 50  This entry revisits Raw images have no header, and the filename
          carries the video mode.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A reader landing on entry 6 from a search is now told, above the prose, that
part of it is wrong. That is the entire argument for an append-only log being
safe to read, and it costs one line of front matter per correction.&lt;/p&gt;
&lt;h2 id=&quot;two-things-the-real-log-changed&quot;&gt;Two things the real log changed&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The feed was 263 KB.&lt;/strong&gt; Carrying all 55 entries at full content makes a feed
that no reader wants and that is slower to fetch than the site. It is capped at
the newest 25 now, with a &lt;code&gt;rel=&quot;alternate&quot;&lt;/code&gt; link to &lt;code&gt;log.json&lt;/code&gt; for anything that
wants all of it. The distinction is worth stating plainly, because the spec had
been sloppy about it: the feed is for &lt;em&gt;reading&lt;/em&gt;, &lt;code&gt;log.json&lt;/code&gt; is the complete
document and the thing to ingest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Raw markdown was leaking into link previews.&lt;/strong&gt; An entry&#39;s summary is derived
from its first sentence, and that sentence contains markup:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;og:description: &quot;`.RAW` is 8-bit indexed pixels, top to bottom, ...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Backticks in a link preview look like a bug because they are one. Summaries are
reduced to their text now wherever markup would be shown literally rather than
rendered - the meta tags, the feed summaries, the index blurbs.&lt;/p&gt;
&lt;h2 id=&quot;publishing-and-what-is-honestly-not-built&quot;&gt;Publishing, and what is honestly not built&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;dir&lt;/code&gt; target works, and the incremental comparison does what it was
specified to do. Editing one entry:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns publish --target site --dry-run
changed: 53
54 unchanged
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The manifest is the &lt;code&gt;log.json&lt;/code&gt; already sitting at the destination, so there is
no state file in the working tree to go stale.&lt;/p&gt;
&lt;p&gt;The reserved &lt;code&gt;http&lt;/code&gt; target earns its place already:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns publish --target hosted --dry-run
POST https://example.invalid/ingest
content-type: application/json
authorization: Bearer $(CAIRNS_TOKEN)
content-length: 264869
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is a real ingest request, from a real log, with no server anywhere. Run
without &lt;code&gt;--dry-run&lt;/code&gt; it refuses rather than pretending.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;git-branch&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; built, and &lt;a href=&quot;https://toyz.github.io/cairns/docs/spec/publish/&quot;&gt;publish.md&lt;/a&gt; claimed
it was - written when the spec was describing intentions rather than code. It is
marked reserved now. A CI job that runs &lt;code&gt;build&lt;/code&gt; and hands the directory to the
host&#39;s own deploy action does the same work without this tool force-pushing
anything, which is both safer and what most projects already have; the workflow
is in the spec and in this repo.&lt;/p&gt;
&lt;h2 id=&quot;a-test-that-was-wrong-about-the-code&quot;&gt;A test that was wrong about the code&lt;/h2&gt;
&lt;p&gt;One renderer test asserted an entry&#39;s prose appears once on its page. It appears
three times, and the code is right: the derived summary lands in &lt;code&gt;description&lt;/code&gt;
and &lt;code&gt;og:description&lt;/code&gt; as well as the article. The assertion now checks the
rendered paragraph and the meta tag separately. Worth recording because the
first instinct on a red test here was to go looking for a duplication bug that
was never there.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The command was stricter than the format</title>
<id>https://toyz.github.io/cairns/5-the-command-was-stricter-than-the-format</id>
<link rel="alternate" href="https://toyz.github.io/cairns/5-the-command-was-stricter-than-the-format"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="cli"/>
<category term="spec"/>
<summary>Asked whether an entry can sit in more than one area, the honest answer was yes - and then --area &quot;decomp, engine&quot; turned out to be rejected while --area decomp,engine worked.</summary>
<content type="html">&lt;p&gt;Asked whether an entry can sit in more than one area, the honest answer was yes&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;and then &lt;code&gt;--area &quot;decomp, engine&quot;&lt;/code&gt; turned out to be rejected while
&lt;code&gt;--area decomp,engine&lt;/code&gt; worked.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The spec is explicit that both spellings are read: hellbender&#39;s log carries 31
entries written &lt;code&gt;format, tooling&lt;/code&gt; and 19 written &lt;code&gt;decomp,format&lt;/code&gt;, and
&lt;a href=&quot;https://toyz.github.io/cairns/docs/spec/entry/&quot;&gt;entry.md&lt;/a&gt; says a reader accepts either. The front matter
parser does. The command that &lt;em&gt;writes&lt;/em&gt; front matter did not, because clap&#39;s
&lt;code&gt;value_delimiter&lt;/code&gt; splits on the comma without trimming, so the second area
arrived as &lt;code&gt;&quot; engine&quot;&lt;/code&gt; and failed the area check against a name that has no
space in it.&lt;/p&gt;
&lt;p&gt;Both halves were behaving as written. The bug is that they were written to
different rules, and only one of them was the spec. A command that writes a file
has no business being stricter than the one that reads it - the asymmetry is
invisible until someone types the more natural spelling, and then it reads as
the format being fussy rather than the tool being inconsistent.&lt;/p&gt;
&lt;p&gt;Values are now split and trimmed on the way in, for &lt;code&gt;--area&lt;/code&gt; and &lt;code&gt;--files&lt;/code&gt;
alike, and all three spellings produce the same line:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--area decomp,engine        -&amp;gt; area: decomp, engine
--area &quot;decomp, engine&quot;     -&amp;gt; area: decomp, engine
--area &quot; decomp , engine &quot;  -&amp;gt; area: decomp, engine
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-question-was-really-about-the-documentation&quot;&gt;The question was really about the documentation&lt;/h2&gt;
&lt;p&gt;The prose in the skill said several areas may be given. Every example showed
one - the &lt;code&gt;cairns new&lt;/code&gt; line, and the front matter block in the entry format.
Prose under a single-area example does not answer the question the example
raises, which is why the question was asked at all.&lt;/p&gt;
&lt;p&gt;Both examples now carry two areas, generated from the project&#39;s own first two,
and the sentence saying so sits directly under the command rather than below the
table. The typed form and the written form differ by a space, which the example
now shows rather than explains.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The write path, and an init that nearly ate hellbender&#39;s skill</title>
<id>https://toyz.github.io/cairns/4-the-write-path-and-an-init-that-nearly-ate-hellbender-s</id>
<link rel="alternate" href="https://toyz.github.io/cairns/4-the-write-path-and-an-init-that-nearly-ate-hellbender-s"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="cli"/>
<category term="skill"/>
<category term="adoption"/>
<summary>new, init and the skill template close the write path.</summary>
<content type="html">&lt;p&gt;&lt;code&gt;new&lt;/code&gt;, &lt;code&gt;init&lt;/code&gt; and the skill template close the write path. &lt;code&gt;new&lt;/code&gt; refuses an area
&lt;code&gt;cairns.toml&lt;/code&gt; does not declare - naming the ones it does, since the useful part
of that error is the list - and regenerates the index on the way out, so the
index is never stale in the window between creating an entry and remembering to
run &lt;code&gt;index&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This entry was created with it.&lt;/p&gt;
&lt;h2 id=&quot;dates-in-two-places-for-one-reason&quot;&gt;Dates, in two places for one reason&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;cairns-core&lt;/code&gt; computes a civil date from a Unix timestamp with Howard Hinnant&#39;s
&lt;code&gt;civil_from_days&lt;/code&gt;, about ten lines, which keeps the crate dependency-free and
&lt;code&gt;wasm32&lt;/code&gt;-clean. The binary does not use it for &lt;code&gt;new&lt;/code&gt;: it asks &lt;code&gt;jiff&lt;/code&gt; for the
&lt;em&gt;local&lt;/em&gt; date, because an entry written at eleven at night should not be dated
tomorrow, and getting a local date right means timezone data the core has no
business carrying.&lt;/p&gt;
&lt;p&gt;The arithmetic was tested against the epoch, a leap day, and an arbitrary
timestamp. The arbitrary one failed - and the code was right, the expectation
was wrong. Worth recording only because the reflex on a red test is to look at
the code, and the leap day passing first time was the signal that the
conversion was sound.&lt;/p&gt;
&lt;h2 id=&quot;init-froze-hellbender-s-slugs-exactly-as-entry-2-asked&quot;&gt;init froze hellbender&#39;s slugs, exactly as entry 2 asked&lt;/h2&gt;
&lt;p&gt;Adoption on a clean clone, with its fifteen areas declared:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns check          # before
... 13 problems
$ cairns init
froze 13 slugs - these entries keep the names they were published under
$ cairns check          # after
ok
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each of the thirteen gained a &lt;code&gt;slug:&lt;/code&gt; line pinning the name it was published
under, so the two entries whose titles had drifted and the eleven cut mid-word
by the old 60-character rule all keep their URLs. New entries get the better
derivation. Adoption preserves rather than improves, which is the only way it
can be safe.&lt;/p&gt;
&lt;h2 id=&quot;it-also-destroyed-the-thing-it-was-adopting&quot;&gt;It also destroyed the thing it was adopting&lt;/h2&gt;
&lt;p&gt;The first working &lt;code&gt;init&lt;/code&gt; rewrote &lt;code&gt;.claude/skills/worklog/SKILL.md&lt;/code&gt; - 58 lines
deleted - and hellbender&#39;s skill is not a generated file. It carries the rules
that make that log what it is: every claim locatable by a VA against
&lt;code&gt;HELLBEND.EXE&lt;/code&gt; at &lt;code&gt;0x00400000&lt;/code&gt;, a format entry unfinished until the matching
&lt;code&gt;docs/formats/&lt;/code&gt; page exists. All of it gone, in a command whose entire purpose
is to help a project adopt the tool.&lt;/p&gt;
&lt;p&gt;Worse, it announced the opposite:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wrote .claude/skills/worklog/SKILL.md (project section preserved)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The flag behind that message was &quot;a file was already here&quot;, not &quot;its project
section survived&quot;. Two separate failures - a destructive default, and a message
asserting the thing that had just not happened - and the second is the one that
would have let it go unnoticed, because the output read like success.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;init&lt;/code&gt; now leaves an existing skill alone unless it carries the
&lt;code&gt;&amp;lt;!-- cairns:project --&amp;gt;&lt;/code&gt; marker, and says why:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kept .claude/skills/worklog/SKILL.md - it has no &amp;lt;!-- cairns:project --&amp;gt; marker.
Everything below that marker is what init preserves, so put it above the parts
this project wrote and run init again.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the marker added, regeneration rewrites the generic half from the template
with the project&#39;s own areas in it and returns the project section verbatim.
Both paths are now checked against a fresh hellbender clone.&lt;/p&gt;
&lt;p&gt;The general rule this is an instance of: a command that writes into a repo it
did not create must default to refusing, not to overwriting, and must never
describe what it wishes it had done.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The index generates, and hellbender&#39;s own index proves it</title>
<id>https://toyz.github.io/cairns/3-the-index-generates-and-hellbender-s-own-index-proves-it</id>
<link rel="alternate" href="https://toyz.github.io/cairns/3-the-index-generates-and-hellbender-s-own-index-proves-it"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="cli"/>
<category term="site"/>
<category term="adoption"/>
<summary>cairns index regenerates WORKLOG.md, and rendering hellbender&#39;s 55 entries reproduces its committed index byte for byte apart from the documented area-spacing change.</summary>
<content type="html">&lt;p&gt;&lt;code&gt;index&lt;/code&gt; was parked with the rest of the write path, which left a hole nobody
had looked at: &lt;code&gt;check&lt;/code&gt; passed on this repo while it had no &lt;code&gt;WORKLOG.md&lt;/code&gt; at all.
The Python tool would have failed that - comparing the index against a fresh
render is one of the four things its &lt;code&gt;check&lt;/code&gt; did. A check that is quiet about
the artifact most likely to be wrong is worse than no check, because it is
believed.&lt;/p&gt;
&lt;p&gt;So the index renders now, and &lt;code&gt;check&lt;/code&gt; compares:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns check
WORKLOG.md is stale - run `cairns index`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Missing and stale are reported separately. They have different causes - one is
a repo that never ran the command, the other is entries edited since it last
did - and a reader who sees the right one does not have to work out which.&lt;/p&gt;
&lt;h2 id=&quot;the-header-is-the-only-part-a-project-writes&quot;&gt;The header is the only part a project writes&lt;/h2&gt;
&lt;p&gt;Everything in the index is derived except its opening prose, which is
&lt;code&gt;[index] header&lt;/code&gt; in &lt;code&gt;cairns.toml&lt;/code&gt;. The generated default names the project and
says the file is generated; a project that wants to explain what its log is
&lt;em&gt;for&lt;/em&gt; says it better than any generated sentence.&lt;/p&gt;
&lt;p&gt;The area tally is sorted alphabetically rather than in declared order. The
areas themselves are presented in declared order everywhere else, because that
ordering is a taxonomy the project chose - but a tally is a lookup, and a
lookup reads better sorted by the name you are looking for.&lt;/p&gt;
&lt;h2 id=&quot;the-parity-test&quot;&gt;The parity test&lt;/h2&gt;
&lt;p&gt;The acceptance test for the port was always going to be hellbender&#39;s own index:
render its 55 entries and diff against the file in the repo. With its header
copied into &lt;code&gt;[index] header&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ diff committed.md WORKLOG.md | grep -c &#39;^&amp;lt;&#39;
27
$ sed &#39;s/,\([a-z]\)/, \1/g&#39; committed.md | diff - WORKLOG.md | grep -c &#39;^[&amp;lt;&amp;gt;]&#39;
0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;27 of 55 rows differ, and every one of them differs only in the area column,
where &lt;code&gt;decomp,engine&lt;/code&gt; becomes &lt;code&gt;decomp, engine&lt;/code&gt;. Normalise that one spelling and
the two files are byte-identical: same header, same tally, same 55 rows, same
titles, dates, paths and pipe escaping.&lt;/p&gt;
&lt;p&gt;That is the whole intended delta. The log had accumulated both spellings
because the old index printed whichever string the entry happened to carry, and
normalising on write is the documented fix. Worth knowing precisely, though,
because it means adopting cairns costs hellbender exactly one reflow commit on
one column - not a migration.&lt;/p&gt;
</content>
</entry>
<entry>
<title>What hellbender&#39;s own log said about the spec</title>
<id>https://toyz.github.io/cairns/2-what-hellbender-s-own-log-said-about-the-spec</id>
<link rel="alternate" href="https://toyz.github.io/cairns/2-what-hellbender-s-own-log-said-about-the-spec"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="spec"/>
<category term="cli"/>
<category term="adoption"/>
<summary>All 55 of hellbender&#39;s entries parse against the new strict front matter with no errors, and check found two real title-filename drifts the old tool could not see.</summary>
<content type="html">&lt;p&gt;The spec was written against hellbender&#39;s log, so the first thing worth knowing
is whether it actually reads it. Pointed at a fresh clone with a &lt;code&gt;cairns.toml&lt;/code&gt;
declaring the fifteen areas the Python tool hard-coded, all 55 entries parsed:
no front matter errors, no unknown areas, no repeated numbers, and every
&lt;code&gt;area&lt;/code&gt; field read despite the log using both &lt;code&gt;format, tooling&lt;/code&gt; and
&lt;code&gt;decomp,format&lt;/code&gt; spellings across it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;content_hash&lt;/code&gt; verified from outside the tool, which is the property it was
specified for:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cairns export --reproducible -o log.json          # entry 50
  content_hash: sha256:40db750a77e9ed60d1b0e242a409751a63f93d0e91e8b9570d0c2e8a72ff6f20
$ shasum -a 256 worklog/0050-kreash-mix-is-the-end-of-a-table.md
  40db750a77e9ed60d1b0e242a409751a63f93d0e91e8b9570d0c2e8a72ff6f20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;33 of the 55 entries carry an unresolved &lt;code&gt;**Still unknown:**&lt;/code&gt; trailer, so the
open-questions page has something real to show on day one rather than being a
feature waiting for a habit to form.&lt;/p&gt;
&lt;h2 id=&quot;two-entries-had-drifted-and-nothing-had-noticed&quot;&gt;Two entries had drifted, and nothing had noticed&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;check&lt;/code&gt; compares an entry&#39;s filename against the slug its title derives, which
the Python tool never did - it only checked the four-digit prefix. Two entries
fail it for reasons that are not about slug rules at all:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0035-powerups-and-the-sprite-models.md
  title: Powerups, and the sprite models they are drawn with

0037-the-players-guns-and-the-energy-that-feeds-them.md
  title: The player&#39;s guns, and the energy that feeds them
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both had their titles edited after the file was created. Harmless while the
filename is only a filename. Not harmless once it is a URL, which is the
argument for the &lt;code&gt;slug:&lt;/code&gt; field: identity is the slug, the filename follows it,
and a title can be tidied afterwards without breaking a link.&lt;/p&gt;
&lt;h2 id=&quot;adoption-is-not-free-and-that-is-the-useful-finding&quot;&gt;Adoption is not free, and that is the useful finding&lt;/h2&gt;
&lt;p&gt;Eleven more entries fail the same check for a different reason: the slug is now
cut back to a word boundary rather than at exactly 60 characters, so
&lt;code&gt;...-the-picture-foun&lt;/code&gt; becomes &lt;code&gt;...-the-picture&lt;/code&gt;. Better names, but every
existing project adopting cairns would see its files want renaming, and any
already-published link would break.&lt;/p&gt;
&lt;p&gt;So adoption has to freeze what exists rather than improve it. &lt;code&gt;init&lt;/code&gt; against a
log that already has entries should write an explicit &lt;code&gt;slug:&lt;/code&gt; into every entry
whose filename disagrees with its title, pinning the URLs that were already
handed out, and leave the better derivation for entries written from then on.
That is a real change to what &lt;code&gt;init&lt;/code&gt; does, and it was not in the plan before
running this.&lt;/p&gt;
&lt;p&gt;One more gap: no entry uses &lt;code&gt;supersedes&lt;/code&gt;, because the field is new - yet entry
50 exists specifically to overturn a claim entry 6 made about &lt;code&gt;KREASH.MIX&lt;/code&gt;, and
says so in prose. The link is in the log already, just not in a form anything
can read. Backfilling those during migration is worth doing by hand; there are
not many, and they are the most valuable edges in the graph.&lt;/p&gt;
&lt;h2 id=&quot;the-log-found-a-bug-in-the-log&quot;&gt;The log found a bug in the log&lt;/h2&gt;
&lt;p&gt;Writing this entry broke the parser that reads it. The paragraph above
mentions the open-question marker in prose, and the trailer was found with an
unanchored search for the literal, so entry 2&#39;s open question came out as the
tail of a sentence about entry counts. The marker now has to begin a line, and
where several qualify the last one is the trailer - which is what the spec
should have said in the first place, because any log written &lt;em&gt;about&lt;/em&gt; this
format will mention the marker constantly.&lt;/p&gt;
&lt;p&gt;Two entries in, dogfooding has paid for itself.&lt;/p&gt;
</content>
</entry>
<entry>
<title>The format is the asset, so the spec is written down first</title>
<id>https://toyz.github.io/cairns/1-the-format-is-the-asset-so-the-spec-is-written-down-first</id>
<link rel="alternate" href="https://toyz.github.io/cairns/1-the-format-is-the-asset-so-the-spec-is-written-down-first"/>
<updated>2026-09-20T00:00:00Z</updated>
<category term="spec"/>
<category term="core"/>
<summary>The worklog format is extracted from hellbender as its own project, with the spec written before the tool so the format can outlive this implementation.</summary>
<content type="html">&lt;p&gt;The worklog format grew up inside
&lt;a href=&quot;https://github.com/Toyz/hellbender&quot;&gt;hellbender&lt;/a&gt; as &lt;code&gt;tools/worklog.py&lt;/code&gt; plus a
skill, and it works - 55 entries, a generated index, numbering that holds. What
it could not do is leave that repo. Three things were tangled in the one skill
file: the format, the tool, and fifteen hard-coded areas, several of them
specific to one 1996 game. Only the first of those is worth sharing, so this
project separates them and writes the format down as a spec under &lt;code&gt;docs/spec/&lt;/code&gt;
before implementing any of it.&lt;/p&gt;
&lt;p&gt;The tool is one implementation. A worklog whose tooling is lost is still a
worklog, which is the whole reason the markdown stays the source of truth and
nothing here needs a database.&lt;/p&gt;
&lt;h2 id=&quot;what-got-decided&quot;&gt;What got decided&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Rust, one binary.&lt;/strong&gt; The original is 200 lines of Python and the port buys
nothing on its own; it buys distribution. Anyone handed a binary runs it, and
the core compiles to &lt;code&gt;wasm32&lt;/code&gt; with &lt;code&gt;--no-default-features&lt;/code&gt;, which is what keeps
a future hosted version a deployment rather than a rewrite.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The static site renders from &lt;code&gt;log.json&lt;/code&gt;, not from the markdown.&lt;/strong&gt; One path
from entries to structured data, so a second reader can never disagree with the
first about something subtle in an output nobody is looking at. It also means
an ingest contract can be designed against real payloads before any server
exists - &lt;code&gt;publish --target hosted --dry-run&lt;/code&gt; prints the request body today.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Front matter is not YAML.&lt;/strong&gt; It is a strict &lt;code&gt;key: value&lt;/code&gt; subset, defined in
&lt;a href=&quot;https://toyz.github.io/cairns/docs/spec/entry/&quot;&gt;entry.md&lt;/a&gt;, with no quoting, nesting, blocks or comments.
A real YAML parser reads it correctly, but that is a convenience rather than a
promise. The subset has exactly one reading and cannot grow ambiguity, which
matters more for a file format meant to be legible in twenty years than
nesting nobody needs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An entry&#39;s id is &lt;code&gt;{project}/{number}&lt;/code&gt;, not the number.&lt;/strong&gt; Costs one line of
config now; retrofitting it invalidates every URL already handed out the moment
two projects sit in one place.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;content_hash&lt;/code&gt; is the SHA-256 of the file&#39;s bytes&lt;/strong&gt;, so it verifies with
&lt;code&gt;shasum -a 256&lt;/code&gt; and no knowledge of this format at all. It is what lets a
publish send only what changed, and it works identically for a local directory
and for an ingest endpoint that does not exist yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No secrets in &lt;code&gt;cairns.toml&lt;/code&gt;.&lt;/strong&gt; A &lt;code&gt;token&lt;/code&gt; must be an environment variable
reference beginning with &lt;code&gt;$&lt;/code&gt;, and &lt;code&gt;check&lt;/code&gt; fails on a literal - catching the
leak in the commit that introduces it rather than after the push.&lt;/p&gt;
&lt;h2 id=&quot;what-is-built&quot;&gt;What is built&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;cairns-core&lt;/code&gt; parses and validates entries and builds the canonical document.
&lt;code&gt;cairns-site&lt;/code&gt; renders a payload from it, which today is &lt;code&gt;log.json&lt;/code&gt; alone.
The command surface is settled - &lt;code&gt;new&lt;/code&gt;, &lt;code&gt;next&lt;/code&gt;, &lt;code&gt;index&lt;/code&gt;, &lt;code&gt;check&lt;/code&gt;, &lt;code&gt;open&lt;/code&gt;,
&lt;code&gt;build&lt;/code&gt;, &lt;code&gt;export&lt;/code&gt;, &lt;code&gt;publish&lt;/code&gt;, &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;migrate&lt;/code&gt; - and the read half of it
works. The write half is the parity port, and it is not built yet.&lt;/p&gt;
</content>
</entry>
</feed>
