Agent-First Data v0.34.0: The Success That Had Not Finished

by Agent-First Kit Contributors

An error has to explain itself; a success says nothing and is believed. This release is mostly one shape repeated — an operation that returned success while part of what it promised had not happened — found in an install, an uninstall, a borrowed terminal, and a rule the tool wrote for everyone but itself.

An error has to explain itself. It carries a code, a message, a hint, and anyone reading it asks what went wrong. A success carries nothing and is believed on sight — which makes it the easiest place in a program to be wrong for a long time without anyone noticing.

Nearly everything in this release is that one shape. An operation returned success while some part of what it promised had not actually happened, and in each case the gap was invisible precisely because the return value was the good one.

One atomic installation, not two

Installing a file so that a crash can never expose a half-written one is a fixed sequence: create a private temporary file in the same directory, write it, apply permissions, fsync the file, atomically rename it over the target, and then fsync the parent directory. The last step is the one that gets dropped, because everything looks finished without it — rename has returned, the file is visibly there, and the function returns Ok. What has not happened is the directory entry reaching storage. A power cut at that moment can take the new name with it.

This crate had that sequence written correctly in the document layer, and written again — a second time, slightly differently — in skill installation. The second copy was missing the parent fsync. That is the normal fate of a duplicated sequence: not that both copies rot, but that one of them is durable and the other only looks it, and nothing in either file says which.

It lives once now, in atomic_file, and callers bring only what is genuinely theirs: the pre-write guard, because what may be overwritten is a policy question the two writers answer differently, and the error type. Failures name the step they failed at, and one distinction survives into the caller’s message:

$ afdata skill install
{"error":{"code":"skill_write_failed","hint":"the file is installed but its
durability is unconfirmed; re-run to be sure", ...}}

Only the closing directory fsync can fail with the new file already in place. Every other step fails with nothing done. Those are different situations for whoever is reading — one asks for a retry, the other asks for nothing — so the error says which one it is instead of averaging them into “write failed”.

What “removed” was not saying

Uninstall removed the skill file, then removed the bundled reference files, then reported removed: true. The second half ignored its own errors. A managed file that refused to go left a stale reference on disk, and the report said the target was clean.

The reason that particular silence is worse than most: the next status reads SKILL.md to decide what is installed, and SKILL.md is exactly the file that did get removed. So the leftovers are not merely unreported once — they are unreachable afterward. Nothing that runs later can see them.

Asset removal failures now return. And because “the directory is gone” and “the directory is still there” are both ordinary outcomes rather than success and failure, the report says which:

$ afdata skill uninstall --agent claude-code
{"kind":"result","result":{"code":"skill_uninstall","removed_any":true,
  "targets":[{"removed":true,
    "assets_removed":["references/bash.md","references/documents.md", ...],
    "directory_retained":true, ...}]}}

directory_retained: true means the skill’s own directory outlived the uninstall, because it holds files this tool did not install. Those are left untouched — that is the right behaviour, not a fault — but it is the difference between a clean removal and one that left something behind, and the caller should not have to go and look to find out which happened.

Skill install judged the final SKILL.md carefully: is it a symlink, is it managed by this tool, does --force permit replacing it. It judged nothing above that file. create_dir_all walks straight through a symlinked directory without comment, so a workspace shipping .claude/skills/<name> as a link to somewhere else would silently redirect a fixed-name write — and the matching uninstall would delete files wherever the link pointed.

Every directory component below the skills root is checked now, on install, uninstall, and status. Two things are deliberately not checked. The root itself: the caller chose it, and a home directory that is a link onto another volume is ordinary. And the final component: installing over it is a rename, which replaces a link rather than following it.

--force no longer offers itself in the hint when containment is what failed. It is permission to replace an unmanaged file inside the root; it was never permission to write outside it, and a hint that suggests otherwise is an invitation to do the wrong thing twice.

A terminal you borrowed and have to hand back

Reading a secret from a prompt means turning off terminal echo, and this crate did it by spawning stty. That resolves a program name through PATH in the moment immediately before a secret is typed, and it makes the one thing standing between the value and the screen depend on a program being installed at all.

It talks to the terminal directly now. Two things behind that change were real defects rather than preferences. tcsetattr reports success having applied only part of what it was asked, so what it did is read back and checked — and if echo is somehow still on, the original settings go back before the read is refused, because a half-applied change is still a change. And the guard that restores the terminal afterward was discarding its own failure, which leaves a person typing into a shell that displays nothing and looks broken, with no clue why. A failed restore is announced on the terminal itself, which is where the damage is, rather than through the caller’s structured output:

warning: could not restore terminal echo; run `stty echo` to fix this terminal

The same read now works on Windows, which it never did. That platform had a refusal — the prompt source is unsupported on this platform — and the refusal was honest, but the gap behind it was real: a person on Windows wanting to type a credential had to route it through an environment variable or a file instead, which are both worse places for it to be.

The implementation reads CONIN$ and writes CONOUT$, which are that process’s own console the way /dev/tty is its controlling terminal — so the prompt still appears when stdout is redirected, and the structured output stream stays clean. Echo comes off through SetConsoleMode, verified by reading the mode back.

One decision there is worth naming, because the obvious implementation is wrong in a way that would not show up in most testing. The console handle implements Read, and reading from it goes through ReadFile, which answers bytes in the console’s current input code page. On a console whose code page is not UTF-8 — the default on a great many installs — a secret containing anything outside ASCII comes back as different bytes, and the value that gets stored is quietly not the value that was typed. ReadConsoleW answers UTF-16, which converts losslessly. Verified on a console running code page 936, where a passphrase mixing Han characters, a Greek letter, and an emoji round-trips byte for byte — the emoji being a character that code page cannot represent at all.

A build without the libc feature, which is where the terminal calls come from on unix, now refuses the prompt source rather than falling back. Reading a secret with the echo still on is not a degraded read. It is the wrong one.

A rule the tool did not follow

Every command-line reference generated from this crate’s spec carries a promise about error messages, and it used to read: No error message quotes a raw value it was given. Absolute, easy to remember, and contradicted by the most ordinary command in this very tool — afdata value /no/such/file.json some.path quotes both the path and the dot-path, and is far more useful for doing so.

That is not a cosmetic problem. A rule with an obvious exception is a rule you learn to read past, and the thing it was actually protecting — that an error event is routinely logged, and a header or a form field or a proxy URL may hold a credential — gets read past with it. It says what it means now: never quote a value from an argument able to carry a credential; do quote a path, a dot-path, a session name, because an error that cannot say which one it means is not worth logging either.

CommandSpec also gained reference_note, for the paragraph that belongs to no single argument. Without somewhere to put it, that paragraph gets hand-written into a file whose own header says it is generated — where the next regeneration deletes it, silently, and nobody finds out until they go looking for something that used to be documented.

Gates that can fail, and bytes that cannot be replaced

Two changes are about the machinery around the code rather than the code.

The shell lint ran when shellcheck happened to be on PATH and skipped when it was not, so a missing tool and a clean tree printed the same thing. Accepting whatever version was present is the subtler half of the same bug, and it is the half that cost a release: 0.9.0 reports a diagnostic on an A && B || C whose C is an error handler, 0.11.0 recognizes the handler and stays quiet, so a tree that passed locally failed on a runner packaging the older one. The version is pinned and provisioned now, the check walks the whole checkout rather than two hand-named files, and on a platform upstream publishes no build for it says so rather than printing nothing and looking clean.

And publishing release assets trusted the workflow’s own ref plus --clobber, which meant a manual dispatch could build a non-tag commit under a tag’s name and overwrite archives whose hashes were already published. The build verifies HEAD is the tag’s own commit, and an existing asset is compared byte for byte: identical is an idempotent success, different is refused.

What this release was about

The last release was about a verdict routed somewhere nothing could act on it. This one is about the return value that says everything went fine.

Each of these was a success that had not finished — a rename not yet durable, an asset not actually removed, a terminal not handed back, an upload that replaced what it should have refused to touch. None of them announced itself, because the one thing they all did correctly was return the value that means there is nothing to look at.

The general lesson is not “check more errors.” It is that a success has to be as specific as an error is. removed: true was not wrong so much as it was under-described, and the fix was not a new error code but two more fields saying what was actually done. When an operation can finish in more than one way, the report has to be able to say which one — otherwise the caller is left to assume, and the assumption they will make is the good one.