A worklog kept as numbered markdown entries: write them, check them, publish them.
A cairn is a stack of stones marking a trail so whoever comes next does not have to work the route out again. That is what a worklog entry is for, and especially the entries recording what did not work - the format that turned out not to be what it looked like, the encoding that failed, the function that was dead code. Code says what; a worklog says how it was found out, and why it is that way.
worklog/0050-kreash-mix-is-the-end-of-a-table.md the entries
WORKLOG.md generated index
cairns.toml project, areas, targets
Each entry carries front matter - number, title, date, area, and optionally the files it is about, a summary, and the earlier entries it corrects. The log is append-only: a claim that turns out to be wrong is overturned by a later entry that links back to it, never by editing the original. Being wrong on the record is the point.
Usable. Every command works except the git-branch publish target, which is
reserved in favour of a CI job. This repo keeps its own worklog with it.
Tested against two real logs rather than fixtures. Hellbender's 55 entries: the
generated index matches its committed one byte for byte apart from one
documented change, init adopts the repo cleanly, and build produces 57
pages that all parse. Amber's 192-entry single-file log migrates with its 7,329
prose lines identical on both sides, and renders in 95 ms.
cairns next |
the number the next entry takes |
cairns init |
set up a repo: config, skill, and freeze existing slugs |
cairns new |
start an entry, numbered and dated, index refreshed |
cairns index |
regenerate WORKLOG.md from the entries |
cairns check |
numbering sound, front matter complete, filenames honest, index current |
cairns open |
what the log still does not know, across every entry |
cairns export |
the whole log as log.json |
cairns build |
render the site, feed, search index and log.json |
cairns serve |
the site on localhost, rebuilt as entries change |
cairns publish |
deliver the payload to a configured target |
cairns migrate |
convert a single-file worklog into numbered entries |
curl -fsSL https://raw.githubusercontent.com/Toyz/cairns/main/install.sh | shHomebrew - this repo is its own tap, so there is no second repository to add:
brew tap Toyz/cairns https://github.com/Toyz/cairns
brew install cairnsOr, with a Rust toolchain:
cargo install --git https://github.com/Toyz/cairns cairnsThat one reports its version as 0.0.0-dev, because the repo never claims a
release number - the tag does, and CI applies it. The binary is the same; only
--version is uninformative.
The script takes the right binary for your platform from the latest
release and checks it against that
release's SHA256SUMS before installing anything. It is not on crates.io, so
those are the ways in. CAIRNS_VERSION pins a tag,
CAIRNS_BIN_DIR chooses where it lands (default ~/.local/bin). Binaries are
built for macOS on both architectures, Linux gnu and musl, and Windows.
Nothing needs installing to read a published worklog; the binary is for keeping one.
cairns init # config, skill, index
$EDITOR cairns.toml # the areas are yours to choose
cairns new "What you found out" --area design
cairns check
cairns serve --open # look at itinit in a repo that already keeps a worklog is safe: it freezes the slug of
every entry whose filename no longer matches its title, so nothing is renamed
and no published link breaks, and it will not touch a hand-written skill.
cairns mcp # read-only, over stdin and stdout
cairns mcp --write # also allows writing entriesA stdio MCP server, for a host with no shell. Tools cover listing, reading,
searching, the open questions and check; --write adds one for writing an
entry, and its schema offers only the areas this project declares. Resources -
worklog://entry/50, worklog://open, worklog://index, worklog://log.json -
let a model pull one entry into context instead of reading the whole log.
{ "mcpServers": {
"worklog": { "command": "cairns", "args": ["mcp"], "cwd": "/path/to/repo" }
} }In a host that already has a shell this adds little - cairns init writes a
skill that teaches the CLI, which is enough. It earns its place where there is
no terminal.
The tag is the version. Cargo.toml says 0.0.0-dev and never says anything
else - pushing v0.2.0 makes CI rewrite the workspace version from the tag,
build the binaries and attach them with their checksums. Nothing to bump by
hand, and no way for a tag and a manifest to disagree.
git tag v0.2.0 && git push origin v0.2.0Publishing to crates.io is off unless the repository variable
PUBLISH_TO_CRATES is true and a CARGO_REGISTRY_TOKEN secret exists.
The spec is in docs/spec/ and is versioned separately from this tool, because the tool is one implementation and the format is the part that has to survive. A worklog whose tooling is lost is still a worklog - which is why the markdown is the source of truth, everything else is generated and disposable, and nothing here needs a database.
- entry.md - the entry file
- config.md -
cairns.toml - log-json.md - the canonical export
- publish.md - targets and the payload
Entry pages with prev/next and clean URLs, an index with area filters and
search, an Atom feed, and an open questions page collecting every
unresolved **Still unknown:** in the log - 33 of them across hellbender's 55
entries, which is the page that log never had.
An entry that corrects an earlier one says supersedes: 6 in its front matter,
and the entry it overturns then says so at the top of its own page. A reader
arriving from a search is told the claim was revisited before they read it.
Every page carries its own metadata, so a link to one entry unfurls with its title and summary rather than the repo's name.
Point [site] readme at a markdown file and it becomes an About page, so a
reader arriving at a worklog can find out what the project is. Its relative
links are rewritten into [project] repository, because docs/spec/entry.md
means a file in the repo, not a page on the site.
[project]
repository = "https://github.com/Toyz/cairns"
[site]
readme = "README.md"build is pure and publish has the side effects. The dir target exists and
publishes incrementally, comparing per-entry hashes against the log.json
already at the destination - no state file to go stale. For GitHub Pages, a CI
job runs build and hands the directory to deploy-pages; the workflow is in
docs/spec/publish.md.
An HTTP ingest target is specified and reserved, so that today, with no server anywhere:
cairns publish --target hosted --dry-runprints the exact request body such a server would receive. The contract can be
designed against a real log before it is built, and if it never is built,
nothing has been spent - log.json is already what the local site renders from.
Extracted from the worklog kept while reverse engineering hellbender, where the format earned its keep over 55 entries. See worklog/ - this repo keeps its own.
MIT.