Install
Two separate acts: the binary, which is the tool, and the skills, which teach an agent how to use it. The skills do not install the binary, and the binary does not install the skills unless you ask it to.
If you only want the short version, it is two lines:
npm install -g @haksolot/ank # the binary for your platform
ank skills --install # the skills, for your agent
Everything below is for the cases those two do not fit.
What you need
- git 2.34 or newer. Not a convenience: claims live in git refs, and Ank checks the version at startup.
sh. Verifiers run undersh -con all three operating systems. On Windows it comes with Git for Windows, so requiring git makes it free.- Rust 1.95 or newer, only if you build from source.
The binary
Three routes install ank and no more (ADR-221aa5da440a): each is a command served from this repository and derived from a published release, and none of them owes anything to a registry with a gatekeeper of its own.
npm, on any machine that has node, and the one to reach for behind a firewall that blocks downloading a bare executable but lets a registry through:
npx @haksolot/ank --version
npm install -g @haksolot/ank
The binary is inside the package: one package per platform, installed through
optionalDependencies, and no postinstall fetches anything. A postinstall
download would die behind the very firewall this channel exists to cross, and
would do it after the install looked like it had worked.
It covers linux x64, darwin arm64 and win32 x64. On anything else, an
Intel Mac or a linux arm64 box, the wrapper exits 9 and names cargo install,
which is the honest answer rather than a silent failure.
curl | sh, on Linux and macOS:
curl -fsSL https://raw.githubusercontent.com/haksolot/ank/main/install.sh | sh
It reads your platform from uname, fetches the archive and the .sha256
published beside it, and refuses before unpacking if the two disagree. On a
platform no release carries it refuses by name and lists what does exist, rather
than ending in silence.
A PowerShell one-liner, on Windows, the same shape as the line above it:
irm https://raw.githubusercontent.com/haksolot/ank/main/install.ps1 | iex
It verifies the .sha256 the same way, runs under Windows PowerShell 5.1 as
well as PowerShell 7, and moves an ank.exe that is currently running aside
instead of failing to overwrite it, so an upgrade works from a shell that
already has ank on its PATH.
Neither of the two below is a route this project offers; both are named because
they are the honest answer when none of the three fits. The releases
page carries the archives the
two installers fetch, one per target with a .sha256 beside it, and unpacking
one by hand is exactly what those installers do for you. And the tree builds:
cargo install --git https://github.com/haksolot/ank ank-cli
--git because nothing publishes to crates.io. That puts ank in
~/.cargo/bin, and needs Rust 1.95 or newer and a C compiler.
No package manager ships ank. Homebrew, Scoop, apt, winget and the AUR each carried a channel here or an attempt at one, and every one of them was withdrawn: the measurements are in ADR-221aa5da440a, and putting one back is a supersession of that decision rather than an addition beside it.
Whichever you took, check it answers:
$ ank --version
ank 0.8.0 (8310e75, skill 0d916cc3d9a5)
Three components: the version, the commit it was built from, and the revision
of the skill it was built alongside. The commit matters the first time you
suspect the binary in your hand is older than the behaviour you are reading
about. The revision answers the same question about the other half: it is the
value skill/SKILL.md carries under metadata.revision, so an agent that has
loaded the skill holds a string it can compare against the one its tool prints,
and can see for itself that its instructions predate its binary. Worth checking
when the skill came from a clone and the binary came from a release: those are
two points in history, and nothing forces them to be the same one.
The same executable carries every surface. A client that has no shell reaches
the verbs over MCP through ank mcp, and the background cache warmer is ank watch: there is nothing further to install for either (ADR-1ea31c2f3c5a).
The MCP server says how a client is configured.
Where the binary you run comes from
Worth stating once, because it costs time exactly where nobody expects it: a
globally installed ank tracks the published release, not the tree you have
checked out. The two are the same file only on the day of a release.
For most repositories that is the whole story: you are using Ank, not changing
it, and the published binary is the one you want. It matters when you are
working on a repository whose corpus is written by a binary newer than yours:
somebody else’s release, or your own tree if you are contributing to Ank
itself. Then the tool managing the work and the tool being changed are different
versions, and both print the same ank 0.8.0. Only the commit separates them,
which is why --version carries it.
Contributors hit the sharper form of this. Building from source puts a binary in
target/, and running that one is what tests a change. But on Windows a
running executable cannot be relinked, so a command that rebuilds the tree while
target/debug/ank is the process running it fails on the lock. The habit that
avoids it is to copy the built binary somewhere outside target/ and run the
copy, which means the binary managing the work drifts from the tree the moment
the tree moves. Rebuild and re-copy it after a merge, or accept that it answers
about the code it was built from.
One half of this the tool diagnoses on its own. A corpus whose entities declare a schema newer than the binary reads is refused entity by entity, so every verb that lists would answer short of them without a word; instead each says so first:
$ ank find --type task
warning: corpus at schema 5, this binary reads 4: 1 entity left out of every listing
-> no release is known to read schema 5: ank --version names the build, build from the tree or wait for a release
TASK-c971 [open] Ordinary task
It warns and still answers, because the entities this build does understand are worth having, and a corpus mid-migration is a real state rather than a broken one.
The second line is one of two, and which one depends on whether a release can
help. The schema a published version reads is stamped into the binary at build
time, from the newest tag’s own source, so the message names the road that
actually resolves the state rather than the one that sounds like it does. Above,
no release reads the corpus – a schema that landed on the default branch after
the last tag, which is the ordinary case for a contributor – so it sends you to
the tree. Where a published release does read it, the binary in your hand is
simply old, and the line names the install instead:
-> the binary is older than the corpus: ank --version names the build, npm install -g @haksolot/ank replaces it.
Naming the install in the first case would fetch the build that had just refused, and a reader who follows advice that visibly does nothing concludes the tool is broken rather than that their copy is old.
The other half, an old binary reading an old corpus, is not detectable: nothing
in the files says a newer format exists. That one is --version, the update
below, and the paragraphs above.
Updating
ank update --check reads the latest release from the repository releases are
published from, with git ls-remote --tags, and installs nothing:
$ ank update --check
running 0.8.0
latest 0.8.0
up to date
It exits 0 whether or not a newer release exists, because 8 belongs to check,
and when one does its last line says a newer release exists: ank update installs it. A script branches on newer:
$ ank update --check --json
{"contract":1,"current":"0.8.0","latest":"0.8.0","newer":false}
ank update installs that release through the route that placed the binary you
are running: npm install -g @haksolot/ank@<version> for a binary inside the npm
package, and otherwise the installer for your platform, told the directory the
binary already sits in. It downloads and unpacks nothing itself, so the checksum
is verified where it always was. At or above the latest release it installs
nothing and says so:
$ ank update
running 0.8.0
latest 0.8.0
up to date
Otherwise it prints the command it hands the install to before running it, and
exits with that command’s code. --version <v> installs the release it names,
an older one included. It never installs the skills: when the release it
installed carries another skill revision than the binary it replaced, it names
ank skills --install in one line and leaves running it to you.
Only this verb asks: no other verb checks for a newer release or announces one,
so nothing reaches the network for it until you run ank update. A binary built
under a cargo target directory was placed by no route, so update refuses it at
exit 7 and names cargo build instead. Where the releases are read from is
ANK_UPDATE_REPOSITORY.
The skills
Six plain markdown files, one per skill. Each is the only copy that exists in git. Every route below points at it, or, for the binary, carries the copy its build read, so no route holds a copy somebody keeps in step by hand.
ank skill/SKILL.md the contract
ank-plan skill/plan/SKILL.md interview a goal into ADRs, specs and tasks
ank-drift skill/drift/SKILL.md audit decisions against the code
ank-loop skill/loop/SKILL.md work the backlog autonomously
ank-tdd skill/tdd/SKILL.md drive an implementation test-first
ank-diagnose skill/diagnose/SKILL.md work a defect back to its cause
skill/SKILL.md is
the one an agent loads by default, and it is self-sufficient: why ank is shaped
as it is, the verbs grouped by the moment each is used, and the rules that are
not negotiable. It names the other five so an agent reaching for an activity
knows what to load, and never depends on them being installed. The five carry a
policy each and are loaded when the activity calls for them (ADR-e4a5a8873fe3).
What an agent does with them once installed is Multi-agent
work.
The routes:
ank skills --install from the binary you installed, nothing cloned
npx skills add haksolot/ank a machine with node and no ank
/plugin marketplace add haksolot/ank Claude Code, as a plugin
pi install npm:@haksolot/ank pi, binary and skill together
pi install git:github.com/haksolot/ank pi, from source
by hand copy skill/SKILL.md where your harness loads it
From the binary
The build embeds the six files, so the binary in your hand already carries the
skills written for it. ank skills lists them, with the revision each file
declares. The revisions below are the ones this page was written against; yours
are whatever ank --version names, and printing them is what lets you compare:
$ ank skills
ank 0d916cc3d9a5 Read a repository's tasks and binding constraints, claim work, and finish it with proof. Use when working in a repo that has a .ank/ directory.
ank-diagnose b5d9c0b96462 Work a defect back to its cause before changing anything, and close it with a regression test. Use when a claimed task's criterion names a defect in a repository with a .ank/ directory.
ank-drift 36cf5808e95e Audit the decisions in .ank/ against the current code and report what no longer holds. Use when asked whether ADRs, specs, or tasks are still accurate, after a milestone, or when the corpus and the code seem to disagree.
ank-loop 9f00f607cdb8 Work through the open tasks in .ank/ without supervision, one claim at a time. Use when asked to work the backlog, chain tasks, or run autonomously in a repository with a .ank/ directory.
ank-plan 83130b664c7e Interview a goal into decisions and tasks recorded in .ank/. Use when someone brings a feature, change, or problem to plan before implementation in a repository with a .ank/ directory.
ank-tdd 96d151e6812e Drive an implementation test-first, red before green, against a claimed task's frozen criterion. Use when implementing a task in a repository with a .ank/ directory.
One line per file: the name, the revision that file declares, and the
description whole, wrapped by nothing. The revisions are the build’s, not the
repository’s, which is what makes them comparable with the one --version
printed. Run inside a corpus, the same verb prints a second block under that
listing, which Multi-agent work
reads.
ank skills --install writes them into a new directory under the temporary
directory and hands that directory to the skills CLI below. It never asks: the
flag is the consent. It prints two lines of its own, wrote 6 skills to <directory> and running: npx skills add <directory>, and everything after
them is the skills CLI’s. Run from a Claude Code session, that part went on
like this:
● claude-code_2-1-270_agent Agent detected — installing non-interactively
◇ Source: C:\Users\you\AppData\Local\Temp\ank-skills-25808-138072400-0
◇ Local path validated
◇ Found 6 skills
● Installing all 6 skills
...
◇ Installed 6 skills
✓ .\.agents\skills\ank
universal: Amp, Antigravity, Antigravity CLI, Cline, Codex +15 more
...
✓ .\.agents\skills\ank-tdd
universal: Amp, Antigravity, Antigravity CLI, Cline, Codex +15 more
└ Done! Review skills before use; they run with full agent permissions.
Nothing is cloned: the files are the binary’s, so they match the build
ank --version names rather than whatever the repository holds today. npx
itself is not offline, and fetches the skills CLI the first time it has none
cached. With an agent detected, the run above installed copies into
.agents/skills of the directory it ran from, and they outlive the temporary
directory. Without npx on your PATH, the verb prints the directory it wrote
and the command to run later, and exits 0.
The skills CLI
The route for a machine that has node and no ank. Detects what you run (Claude Code, Codex, Cursor, OpenCode and some thirty more) and links each one to a single copy. Ask it what it found before you let it install:
$ npx skills add haksolot/ank --list
Source: https://github.com/haksolot/ank.git
Repository cloned
Found 6 skills
Available Skills
Ank
ank
Read a repository's tasks and binding constraints, claim work, and
finish it with proof. Use when working in a repo that has a .ank/
directory.
ank-diagnose
Work a defect back to its cause before changing anything, and close it
with a regression test. ...
ank-drift
Audit the decisions in .ank/ against the current code and report what
no longer holds. ...
ank-loop
Work through the open tasks in .ank/ without supervision, one claim at
a time. ...
ank-plan
Interview a goal into decisions and tasks recorded in .ank/. ...
ank-tdd
Drive an implementation test-first, red before green, against a claimed
task's frozen criterion. ...
Use --skill <name> to install specific skills
Drop --list to install them all, or name one with --skill. That route
installs the skills, not the binary.
This is the widest route and the least anchored one. It finds the skill through
its own recursive scan rather than through a manifest, skill/ not being one of
the directories it looks in by name, so it works because the fallback works. If
a future version of that CLI narrows its search, this is the route that breaks
first, and the hand copy below is the answer.
Claude Code, as a plugin
This repository serves as its own marketplace:
/plugin marketplace add haksolot/ank
/plugin install ank@ank
claude plugin details ank then tells you what it costs, which is the question
worth asking of anything loaded on every session:
ank 0.8.0
Description: Read a repository's tasks and binding constraints, claim work, and finish it with proof.
Source: ank@ank
Component inventory
Skills (6) ank, ank-diagnose, ank-drift, ank-loop, ank-plan, ank-tdd
Agents (0)
Hooks (0)
MCP servers (0)
LSP servers (0)
Projected token cost
Always-on: ~409 tok added to every session
Per-component (rounded)
component always-on on-invoke
ank ~50 ~2.2k
ank-plan ~70 ~930
ank-drift ~80 ~540
ank-loop ~70 ~1.6k
ank-tdd ~60 ~1.3k
ank-diagnose ~70 ~1.9k
On-invoke cost is paid each time a skill or agent fires.
Token counts are estimates and may differ from actual usage.
The four zeroes are the inventory: the plugin is six skills and nothing else –
no agent, no hook, no server of any kind, so nothing of it runs unless you call
ank yourself. The Description line is the ank skill’s own rather than the
one plugin.json carries, and the counts are estimates that move with the skill
files and with whatever does the counting.
Read the two columns as what they are. Always-on is six descriptions, paid by every session whether or not anything fires; on-invoke is a body, paid by the session that wanted it. Splitting the teaching moved cost from the second column to the first, which is the trade the plural skill system makes: an agent executing a task no longer loads the planning policy it will not use, and every session pays a little more to know the policies exist. The ceiling on the bodies is kept anyway, because the by-hand route below copies whole files into whatever a harness loads, and some harnesses load all of it every session, so it bounds the worst route rather than the measured one.
pi
From the registry, or from a clone:
$ pi install npm:@haksolot/ank
$ pi install git:github.com/haksolot/ank
Installed git:github.com/haksolot/ank
The git route clones the repository and reads its pi manifest; the npm route
takes the published package, which carries the skill beside the binary.
One thing to expect from the npm route: pi loads resources, it does not put
executables on your PATH. The binary is inside the package it installed, and
ank will still not be a command you can type until you install it by one of
the routes above.
By hand
Each skill is one file with nothing generated in it. Where none of the routes
above fits your harness, copy skill/SKILL.md into whatever that harness loads
and you have lost nothing: the routes exist to save you a copy, not to add
anything to it. Copy skill/plan/SKILL.md, skill/drift/SKILL.md,
skill/loop/SKILL.md, skill/tdd/SKILL.md and skill/diagnose/SKILL.md
beside it for the activity policies, or copy none of them and keep the
contract, which stands alone.
Next: the quickstart, from ank init to a first finished task.
Quickstart
By the end of this page your repository holds one binding constraint, one
finished task, and a proof that Ank wrote after running the verification
itself. It takes about ten minutes, and nothing here asks you to read the
specification. It assumes ank answers ank --version; Install
gets it there.
Every command and every output below was run against a fresh repository, and the suite replays them against the binary on every change. Where the tool refuses, the refusal is shown as it appears. Each step says what it does and links to the page that explains it whole.
Initialise a repository
Three files are in the tree before anything starts, because every scope and every verifier below is pointed at one of them. These are their exact contents, one line each, and they are written out because two of the hashes further down are hashes of them:
src/auth/session.ts export function createSession(id) { return store.put(id) }
tests/auth.sh exit 0
README.md # auth service
What matters is that all three exist. A scope matching no file and a verifier
whose command is not there are the two ways this walk ends in red, and neither
says anything until ank done, a dozen commands later.
From the root of that repository:
$ ank init
created .ank/entities
wrote .ank/config.yml
wrote .gitattributes
wrote .gitignore
pointer added to AGENTS.md
refspec added: +refs/ank/*:refs/ank/*
The last line is the one a repository with no origin does not print:
there is no remote to add a refspec to, so init reports five effects instead
of six and the other five are identical. Re-running changes nothing either way:
init is idempotent and says already initialised, nothing to do. Whether you
want an origin at all is a question about coordination, and Claims and
identity answers it.
One directory is created, not one per kind: entities live flat in
.ank/entities whatever they are (ADR-c9f9d0d6f05d), and a task, an ADR, a
spec and a log entry are told apart by a field rather than by a folder. The
.gitattributes line keeps .ank/ in LF on checkout: on Windows git would
otherwise convert back to CRLF everything the tool has just written, on every
clone. The .gitignore line is .ank/index.db, the derived SQLite index: it
is rebuilt from the files whenever it is missing, so committing it would only
track a binary that every command rewrites. The refspec is what makes claims
travel, since hosts do not fetch non-standard refs on their own. Both git files
are appended to, never replaced, so an existing .gitignore keeps everything
already in it. The file format describes the whole layout.
Name your default branch. It is the one key init leaves unset on purpose:
it runs where the reference branch is not known yet, and writing main there
would be exactly the guess the tool refuses everywhere else. .ank/config.yml
is written through the CLI rather than by hand:
$ ank config default_branch main
default_branch (unset) -> main
Without it, Ank looks for refs/remotes/origin/HEAD, and a repository with no
remote has none. It refuses rather than guessing, and here is what the verb two
sections down would have said (06d2 is the ADR you write there):
$ ank accept 06d2
error[9]: default branch indeterminable (default_branch absent from .ank/config.yml, refs/remotes/origin/HEAD absent)
-> git remote set-head origin -a
-> or ank config default_branch <name>
A clone sets refs/remotes/origin/HEAD for you, so a repository with an
origin resolves the branch without the key; setting it anyway costs one line
and removes the difference between the two.
The two kinds this page uses
Flat in .ank/, markdown with YAML frontmatter. Four kinds exist and this page
needs two of them; the other two are a spec, normative text that describes
rather than binds, and a log entry, written once and never transitioned.
An ADR is a decision that constrains code. Its constraint is the one
field injected into an agent’s context, so it is short and imperative; the body
holds the reasoning and costs nothing at injection time.
A task is a unit of work. Its done_criteria says what would prove it
finished.
Both carry a scope: a list of globs, and the only thing that joins the two
kinds. There is no epic, no parent, no label. “Everything about the auth
migration” is answered by ank context src/auth/. The full field list is in
Entity fields; you do not need it to work through this page.
Write the first constraint
$ ank new adr --title "Opaque sessions rather than stateless JWT" \
--scope "src/auth/**" \
--constraint "Do not introduce self-contained JWTs for user auth. Every session goes through the Redis store."
created ADR-06d29e727d24 Opaque sessions rather than stateless JWT
It is created proposed, which means visible but not binding. Promotion goes
through one command, and that command is the only one in the tool that makes a
git commit:
$ git add -A && git commit -m "adr: opaque sessions"
$ ank accept 06d2
accepted ADR-06d29e727d24 -> 9c45c50
Short prefixes work everywhere an id is accepted; an ambiguous one is an error
listing the candidates, never a guess. The commit it produced carries the hash
of constraint and scope at acceptance:
ratify ADR-06d29e727d24
constraint+scope: c5d4f3478ad5
by: human:marie
That hash is the anchor. Editing the constraint afterwards does not change it,
which is how ank check notices. accept runs on the default branch only, and
there is no flag around it: a constraint ratified on a feature branch would bind
on that branch alone. How a ratification reaches a protected default branch is
Ratifying, and how others can verify it is Signing
keys.
Declare the verifiers, then the task
A task names verifiers; it never carries a shell command. The definitions live
in .ank/config.yml, and writing a run for a name the file does not carry is
how a verifier is declared:
$ ank config verifiers.auth-tests.run "sh tests/auth.sh"
verifiers.auth-tests.run (unset) -> sh tests/auth.sh
$ ank config verifiers.no-jwt.run "! grep -rq jwt.verify src/auth/"
verifiers.no-jwt.run (unset) -> ! grep -rq jwt.verify src/auth/
Then the task, naming both:
$ ank new task --title "Migrate auth to opaque sessions" \
--scope "src/auth/**" \
--criteria "The auth tests pass and no reference to jwt.verify remains in src/auth/" \
--verify auth-tests --verify no-jwt
created TASK-820d259af6a7 Migrate auth to opaque sessions
A composite criterion is mechanised by several verifiers, not by one that covers half of it, and all of them must pass. What else a verifier can be told, the list a task gets when it names none, and the task that deliberately has no verifier at all are Proof and verifiers.
Orientation
ank context is the first call, and the only one you have to remember. With no
argument it covers the whole repository; with a path it covers that perimeter.
$ ank context src/auth/
CONSTRAINTS (1 active)
ADR-06d2 Opaque sessions rather than stateless JWT
TASKS (1)
TASK-820d [open] Migrate auth to opaque sessions
> ank claim TASK-820d to start
Before a claim a constraint is one line, and that line is its title. What
is being answered here is which perimeter to enter, and a survey that spent its
whole budget on constraint text would answer it worse. The constraint itself
arrives with the claim, below, where the perimeter is settled and the rule is
what you are about to be held to; ank show 06d2 prints it whole at any time.
Constraints come first in both forms, and once you are working they are never
truncated. The output ends with the next command, as every output here does.
Claim
$ ank claim 820d
claimed TASK-820d259af6a7 migrate-auth-to-opaque-sessions -> HEAD
The task moved to in_progress, a claim ref now says who holds it, and its
done_criteria was frozen by hash where the file’s editor cannot reach it.
claim also sets HEAD, so the following commands need no id. What a claim is,
how long it lasts, and why a second session needs an identity of its own are
Claims and identity.
Run ank context again and the output inverts: no other task, the full
criterion, and the constraints matching this task’s scope.
$ ank context
TASK-820d Migrate auth to opaque sessions
DONE_CRITERIA
The auth tests pass and no reference to jwt.verify remains in src/auth/
CONSTRAINTS (1 active)
ADR-06d2 Do not introduce self-contained JWTs for user auth. Every session goes through the Redis store.
ank show 820d gives you the entity whole, frontmatter, body and log, which
is where the reasoning behind a task lives.
Work, and log what you learn
$ ank log "jwt.verify removed from session.ts"
logged LOG-6b0f39d7a4c1 on TASK-820d259af6a7
The log is a work trace, not proof. Write to it when you discover something,
not when you finish: it renews the claim, so working is what keeps the lock and
there is no heartbeat command to remember. Each entry is an entity of its own,
which is why the line names one, and ank log 820d with no message reads them
back, newest first. How entries are stored is the file
format.
Finish
$ ank done
running: auth-tests ... ok (0.0s)
running: no-jwt ... ok (0.0s)
proof recorded: auth-tests@94a1f671c577 -> local/e3b0c44298fc@9c45c50 (scope/18d14da584ab)
proof recorded: no-jwt@791cc818d0ad -> local/e3b0c44298fc@9c45c50 (scope/18d14da584ab)
TASK-820d259af6a7 -> done
This is the point of the tool. Ank ran the verifiers itself and wrote what
actually ran; nobody reported their own result. Never set status: done by
hand: a status written by the party being measured measures nothing. What each
hash on those lines is, and what done asks for when a task has no verifier,
are Proof and verifiers.
Commit, and read check
Ank never commits, except accept. Everything else writes files and leaves
them in your working tree, so the corpus travels through your normal review
like any other change. Before you commit, check has something to say:
$ ank check
signal: ADR-06d29e727d24: ratified by its own author (human:marie)
signal: TASK-820d259af6a7: finished on another branch, main has not caught up
signal: allowed_signers: no ratification key declared: permissions are advisory, not enforced (§8)
signal: corpus: 4 entity file(s) differ from main: this checkout does not carry the corpus the default branch does (git merge main)
check: ok — 1 tasks, 1 adr, 4 signal(s)
Commit, and the two signals about the uncommitted work go:
$ git add -A && git commit -m "the migration is done"
$ ank check
signal: ADR-06d29e727d24: ratified by its own author (human:marie)
signal: allowed_signers: no ratification key declared: permissions are advisory, not enforced (§8)
signal: corpus: 3 hot entities are cold and belong in .ank/archive/entities/, with every entry about them (ank archive)
└── ank archive --dry-run lists them and moves nothing
pruned refs/ank/claims/TASK-820d259af6a7
check: ok — 1 tasks, 1 adr, 3 signal(s)
That is the green this page set out to reach, with an origin and without:
signals, no fault, exit 0. A signal is something a reader should see and never a
failure; exit 8 is reserved for findings. What each of those lines means, and
why check just pruned a ref, is Reading ank check.
When a command refuses instead, the exit code carries the meaning so a script can route without parsing anything, and the message always ends with the exact command to run next. The codes are the exit-code reference.
Starting where there is already code
Everything above happened in an empty directory, which is the one repository
nobody has. ank init on two years of history leaves you with a corpus and no
content, and the question it raises is not how to write an ADR: it is which
decisions this code has already made, and which of them were worth writing down
all along. That reading is an agent’s work, and these are the three prompts that
ask for it. Both installers offer to print the same three, character for
character, and a test holds the three copies together.
Run this once, at the root of the repository:
ank init
Then paste each prompt into your agent, one at a time, reading what comes back before you send the next. They follow the three moments of an adoption: what the code already decided, what it still owes, and whether the answer holds.
What the code already decided. This is the one you judge the tool on. A constraint you recognise on sight is a constraint that was true and unwritten, and the reason it now has an id is that the next agent reads it without being told.
Read this repository and write, as ank ADRs, the decisions its code
has already made: the ones a newcomer would break without knowing
they existed. One ADR per decision, each with a scope glob covering
the files it binds and a constraint stated as a rule. Leave them
proposed; I ratify them myself.
They land proposed, which binds nobody. ank review lists what is waiting, and
ank accept <id> stays yours: a signed act, on the default branch, one at a
time. An agent proposes and says it is waiting.
What it still owes. Intentions scattered across TODOs, an issue tracker and a
README are not work an agent can take. A scope and a criterion are, which is what
ank claim freezes and ank done measures.
Read the TODOs, the open issues and the README of this repository,
and turn what they promise into ank tasks. Give each one a scope
glob and a done_criteria a test could settle, and use blocked_by
only where a task genuinely waits on another.
Whether the answer holds. The third is the uncomfortable one, and it is meant to be: a constraint the code already breaks is the ordinary result of writing down what was implicit, not a sign the first prompt went wrong. What you want out of it is the list, before anybody starts repairing.
Run ank check and ank review here, then read every ADR back against
the code its scope matches. Tell me which constraints the code
already breaks and which scopes match no file, and change nothing
until I have read your answer.
None of the three is a step the tool waits on. A corpus with nothing but the ADRs
from the first prompt is already worth ank context, and the other two can wait
until you want them.
Why it works this way
You have now done the loop once, which is the right moment for the four claims underneath it. Each one is a property of the tool rather than a convention you are asked to keep.
Scope, not hierarchy. Constraints and work are two planes joined only by globs. A rule written last year binds work created today, and a glob is verifiable against the filesystem where a label is not. There is no epic, no parent and no rollup to keep in step.
Nobody declares themselves done. An agent that reports its own result can
simply be wrong, so ank done runs the verifiers itself and records what
actually ran, hashed.
Freezing is verifiable, not defended. The CLI cannot stop you editing a file
and does not pretend to. Frozen fields are anchored by a hash the editor does not
control, and ank check compares. Editing a criterion to unblock yourself
unblocks nothing; it makes the divergence visible.
Git does the hard parts. Claims are git refs, so the compare-and-swap that arbitrates two agents is the one git already guarantees. Undo, history and recovery are git’s, and there is no server to run.
One call is bounded at 8000 characters by default, roughly 2000 tokens, which is
the constraint every one of those choices is paid for by: what context serves
has to fit in a context window beside the code. The normative text behind all of
it is the specification; ank help lists every verb, and
ank help <verb> answers about one.
Proof and verifiers
A task is finished when ank done says so, and ank done says so only after it
has run the task’s verifiers itself and recorded what ran. This page is where
verifiers come from, how a task gets its list, what done writes, and what a
proof is worth depending on who produced it.
The examples run in the repository the quickstart builds: a
src/auth/session.ts, a tests/auth.sh that exits 0, and a default branch
named main.
A verifier is declared once, in config.yml
A task names verifiers; it never carries a shell command. The definitions live
in .ank/config.yml, under the repository’s own review, and they go in through
the same verb as every other key: writing a run for a name the file does not
carry is how a verifier is declared.
$ ank config verifiers.auth-tests.run "sh tests/auth.sh"
verifiers.auth-tests.run (unset) -> sh tests/auth.sh
$ ank config verifiers.no-jwt.run "! grep -rq jwt.verify src/auth/"
verifiers.no-jwt.run (unset) -> ! grep -rq jwt.verify src/auth/
which is what the file then carries:
verifiers:
auth-tests:
run: sh tests/auth.sh
no-jwt:
run: "! grep -rq jwt.verify src/auth/"
ank config --unset verifiers.no-jwt takes one back out. Reading a key says
where the value comes from, this repository or a default the tool resolved:
$ ank config verifiers.auth-tests.run
sh tests/auth.sh
$ ank config verifiers.auth-tests.timeout
10m (default)
The distinction is the reason writing is line surgery rather than a round-trip through a YAML serializer. Your comments, blank lines, key order and quoting survive a write, and a key you never set is never written out: an unset key follows the tool, a written one is pinned here, and a serializer would quietly convert every one of the first kind into the second. Every key a verifier takes is in config.yml keys.
A verifier runs under sh -c from the root of the working tree, on all three
operating systems, and passes when it exits 0.
A task names its list when it is written
Declare the verifiers first. A task that names one config.yml does not know is
refused at creation, so a name you misremember fails when you write the task
rather than at the close:
$ ank new task --title "Migrate auth" --scope "src/auth/**" --verify auth-tests
error[7]: no verifier 'auth-tests' in .ank/config.yml
-> ank config verifiers.auth-tests.run "<command>"
With the definitions in place, --verify names them one at a time, and a
composite criterion is mechanised by several verifiers rather than by one that
covers half of it:
$ ank new task --title "Migrate auth to opaque sessions" \
--scope "src/auth/**" \
--criteria "The auth tests pass and no reference to jwt.verify remains in src/auth/" \
--verify auth-tests --verify no-jwt
created TASK-820d259af6a7 Migrate auth to opaque sessions
The default list
--verify one at a time is the right amount of ceremony for a verifier that
suits one perimeter and the wrong amount for the suite every task in the
repository has to pass. A verifier marked default joins every task written
afterwards:
$ ank config verifiers.no-jwt.default true
verifiers.no-jwt.default false (default) -> true
which lands beside the run it belongs to, and nowhere else in the file:
verifiers:
auth-tests:
run: sh tests/auth.sh
no-jwt:
run: "! grep -rq jwt.verify src/auth/"
default: true
The read form is the same as any other key, and an unmarked verifier answers
false (default) – the tool’s default for the default key, which is the one
place the word does double duty:
$ ank config verifiers.no-jwt.default
true
$ ank config verifiers.auth-tests.default
false (default)
A task created now with no --verify of its own carries verify: [no-jwt] in
its frontmatter, without anybody naming it. --verify replaces that list
rather than adding to it, so a task that names its own verifiers gets exactly
those.
Declining it
--no-verify is the third possibility, and it is a judgement, not a
shortcut. It writes a task with no verify: at all:
$ ank new task --title "Say in the README what a session is now" \
--scope "README.md" \
--criteria "The README describes opaque sessions and names no JWT" \
--no-verify
created TASK-51c2a0f6d418 Say in the README what a session is now
Use it where no declared verifier can settle the criterion – prose a person has
to read, a behaviour only a published release answers – and where that is
genuinely the case, say so when you write the task, so the empty list is
visible in its diff. A task that reaches done with an empty verify: nobody
decided on closes on a proof nothing ran.
What done records
$ ank claim 820d
claimed TASK-820d259af6a7 migrate-auth-to-opaque-sessions -> HEAD
$ ank done
running: auth-tests ... ok (0.0s)
running: no-jwt ... ok (0.0s)
proof recorded: auth-tests@94a1f671c577 -> local/e3b0c44298fc@9c45c50 (scope/18d14da584ab)
proof recorded: no-jwt@791cc818d0ad -> local/e3b0c44298fc@9c45c50 (scope/18d14da584ab)
TASK-820d259af6a7 -> done
One proof entry per verifier, carrying the hash of the verifier definition that
executed, a hash of what it printed, the HEAD commit, and a hash of the scope
files’ content at that moment. Four of those hashes are content and not
identity, so they are the same on your machine as on this page. 94a1f671c577
and 791cc818d0ad are the two verifier definitions; e3b0c44298fc is what each
of them printed, which is nothing; and 18d14da584ab is src/auth/session.ts
as the quickstart wrote it. The commit is the one your tree is on, so that one
is yours.
If a verifier fails or times out, the transition is refused and the task stays where it is.
A task with no verifier needs a proof you hand it. There is nothing for
done to run, so --proof becomes mandatory:
$ ank claim 51c2
claimed TASK-51c2a0f6d418 say-in-the-readme-what-a-session-is-now -> HEAD
$ ank done
error[5]: proof required to move TASK-51c2a0f6d418 to done
-> ank done --proof commit:<sha>
The reverse is refused too: a task that declares verifiers takes no --proof,
because what closes it is what ran and not what somebody typed. Give a proof you
already hold, commit:<sha>, and never a run id you would have to wait for.
What a proof is worth
The proof types are commit, test, human-review and assertion, and what
separates them is not local versus hosted but who controls the environment.
A CI reference is out of the agent’s reach and guarantees the most;
commit:<sha> is checked with git; a local test proves what ran in a tree the
agent could have altered; assertion:"..." guarantees nothing and is marked
weak, which is what keeps it from quietly becoming the default path.
The type is half the answer, and the entry records the other half. Every
proof carries via: verifier when Ank ran the verifier itself, attested
when it arrived on refs/ank/proof/<id>, submitted when a caller typed it,
because a run reference is the strongest thing in that list when a pipeline
wrote it and the weakest when somebody typed it (ADR-b6b69053a47b). Typing
--proof test:<run-id> is still accepted and still recorded; what it does not
do is clear the done with no test proof signal, which stays until a pipeline
anchors the run. Entries written before the field carry no via and are read
exactly as they were.
done records what ran on the machine that ran it, which is a local claim.
Anchoring a run is how a pipeline adds the external
half after the merge, without a commit.
done proves the task in the working tree it ran in. It does not prove the
change merges, or that the combined system works: that gap, and the task that
closes it, are Multi-agent work.
Claims and identity
A claim is how one agent takes a task and every other agent sees that it is
taken. It is a git ref rather than a field, it lasts as long as somebody is
working, and it is held by an identity the caller declares. This page is the
whole lifecycle, from claim to release or done.
What a claim does
$ ank claim 820d
claimed TASK-820d259af6a7 migrate-auth-to-opaque-sessions -> HEAD
Three things happened. The task moved to in_progress. A claim ref appeared at
refs/ank/claims/TASK-820d259af6a7, which is what arbitrates two people or two
agents reaching for the same task: git’s compare-and-swap, one winner. And the
done_criteria was frozen: its hash went into the claim record, where the
file’s editor cannot reach it.
The freeze is the rule worth internalising. Editing the criterion to make the
work fit does not unblock anything; done compares against the recorded hash
and refuses, and check reports the divergence. If the criterion is genuinely
wrong, hand the task back and say so. A subtask you discover is a new task with a
blocked_by, never a softened criterion.
claim also sets HEAD, so the commands that follow need no id. One claim at a
time, per identity.
One identity per session
Claiming a second task while you already hold one is refused:
$ ank claim 51c2
error[7]: human:marie holds a live claim on TASK-820d259af6a7 (expires in 30m)
-> ank release --reason "<why>" (a second session on this machine sets its own ANK_AGENT)
If you meant it, the first way out is the one to take: finish the task you hold
or hand it back. If the refusal surprises you, it is almost certainly two
terminals. $ANK_AGENT names the session, and unset it falls back to
<user>@<hostname>, so two sessions on one machine are the same agent as far as
the refs can tell. Give every concurrent session an identity of its own:
$ ANK_AGENT=human:marie-2 ank claim 51c2
claimed TASK-51c2a0f6d418 say-in-the-readme-what-a-session-is-now -> HEAD
Two sessions sharing an identity are not arbitrated, they are merged.
Claiming the task the identity already holds is granted again at exit 0, so a
second session with no ANK_AGENT of its own silently starts work the first one
is already doing:
$ ank claim 820d
claimed TASK-820d259af6a7 migrate-auth-to-opaque-sessions -> HEAD
So the failure mode is not a message nobody sees. It is two sessions writing the same perimeter under one name, which the refs cannot tell apart and no reader can untangle afterwards. That is why the refusal above names the identity rather than calling you its holder: under a shared identity, the session being refused may have claimed nothing at all.
Write the identity typed. An identity that goes into an entity says what
kind of actor it is (ADR-3877fef1d662): human:<id> is a person,
<producer>/<version> an agent – claude-code/opus-5, and a suffix after +
for one session of it – and process:<id> something automated, which is the
form a pipeline uses. The convention is what lets check say that an entity was
written by an agent and read by no human; the fallback <user>@<hostname>
carries no type and so answers that question with nothing. It is a signal and
not a wall – anyone can type human: in front of a model – and what it buys is
that the ordinary case is legible.
Identity is declared, never proved, and it is deliberately not bound to the
session: a PID or a TTY in it would mean losing your claim to a restarted
terminal. $ANK_AGENT records who was working rather than restricting who may.
Nothing in ank refuses on identity; the refusals are on state, and the one hard
authority line is the signed ratification commit ank accept produces.
The lease, and what renews it
A claim lasts 30 minutes by default and is renewed by working, not by reporting
(ADR-0bb7ea8991bc). Three verbs move the lease, measured by reading the expiry
out of ank status --json before and after each call:
ank context, in every form – bare, with a path, with--json.ank show <id>, when<id>is the task this identity holds.ank showover any other entity leaves the lease alone, so it is the subject that renews and not the verb.ank log "<message>", the appending form.ank log <id>, which reads, does not.
find, status, scope, graph, check, review and help leave the expiry
untouched. So there is no heartbeat command to remember: writing down what you
learned is what keeps the lock. A tool that shows a corpus to somebody must not
put context or show on a timer, and the machine surface
says what to poll instead.
An expired claim is not a live one. If a build ran long and the lease ran out,
the task stays in_progress and you re-acquire it silently, provided nobody
took it over; and nothing stands between anybody and a task whose lease ran out,
yours or another’s. A task that is in_progress in its file with no ref behind
it is simply one whose claim expired.
Handing a task back
$ ank release --reason "the criterion names a test that does not exist yet"
released TASK-820d259af6a7 -> open
The reason is recorded in the task’s log, where the next holder reads it with
ank log <id> before repeating what you tried. Never let a claim lapse in
silence: an expired lease tells the next agent nothing about why.
When done, the claim becomes a completion
done does not delete the claim ref. It turns it into a completion record naming
the commit and the branch the task was finished on, because status: done lives
in the file, therefore on your branch alone until the merge, and during all that
time the task would look free to everyone else. Anyone who tries to claim it
meanwhile is refused with the commit and the branch named, and every other tree
answers finished on another branch (commit …, branch …), not merged here yet.
check prunes the record once the default branch says the task is done:
Reading ank check shows both states.
Where claims travel
Coordination between clones needs a remote named origin, and nothing
else. Whether claims travel is not configured: a repository with an origin
pushes every claim to it as a compare-and-swap, and one without keeps them as
local refs. GitHub is not required. A bare repository reachable over file://
or ssh on the same network is a remote named origin like any other: claim
reads it first, so the second clone to take a task is refused with code 4 and
the holder named, and the push settles a race the read misses. ank init adds
the refspec that makes refs/ank/* travel, since hosts do not fetch
non-standard refs on their own.
Without a remote, git worktrees of a single clone are still arbitrated,
because they share refs/ank/. Two clones with no common origin are not
arbitrated: both claims of one task succeed, both agents work, and nothing
reports it, not status, not check, not later.
A push the remote refuses leaves the claim standing in your clone and says so on stderr: the claim holds there only, and another clone can take the same task. A contributor working from a fork is in exactly that position, and CONTRIBUTING.md says how coordination happens instead.
Reading ank check
ank check validates the corpus: every file parses and round-trips byte for
byte, every blocked_by and reference resolves, every frozen field still
matches its anchor, and every claim ref still means something. It is the verb
you put in CI, and the one to run before adding to a corpus, because it says what
is already known to be wrong.
It answers in two levels, and the difference is the whole of how to read it.
- A fault is something wrong with the corpus: a file that does not parse, a
frozen criterion edited under a live claim, a ratification whose anchor no
longer matches. Any fault makes
checkexit 8. - A signal is something a reader should see and is not a failure: a
decision ratified by its own author, a task finished on a branch that has not
merged, a scope that matches no file yet. Signals leave the exit code at
0, because reddening a build over an observation teaches a team to stop
reading
check.
Exit 9 is neither: the environment, not the corpus – git too old, the default branch indeterminable. The full table is the exit-code reference.
Signals on a finished task, before and after the merge
The repository here is the one the quickstart ends with: one
ADR accepted by the person who wrote it, one task finished with ank done, and
nothing committed since.
$ ank check
signal: ADR-06d29e727d24: ratified by its own author (human:marie)
signal: TASK-820d259af6a7: finished on another branch, main has not caught up
signal: allowed_signers: no ratification key declared: permissions are advisory, not enforced (§8)
signal: corpus: 4 entity file(s) differ from main: this checkout does not carry the corpus the default branch does (git merge main)
check: ok — 1 tasks, 1 adr, 4 signal(s)
Four signals and no fault, so exit 0, and each line starts with its subject.
- Ratified by its own author. You wrote the ADR and you ratified it, and
checksays so rather than deciding what it means: a solo maintainer does that legitimately, and a team may want to know. - Finished on another branch.
status: donelives in the file, so on your branch alone until the merge. The claim ref became a completion record atdone, which is what tells every other tree the task is taken (Claims and identity). - No ratification key declared. Nobody has said whose signature makes a ratification binding, so the corpus says it runs on trust. Signing keys is how that line goes away.
- Entity files differ from main. The same fact as the second line, said about the corpus rather than one task: what your checkout carries that the default branch does not.
Commit, and the completion record is pruned:
$ git add -A && git commit -m "the migration is done"
$ ank check
signal: ADR-06d29e727d24: ratified by its own author (human:marie)
signal: allowed_signers: no ratification key declared: permissions are advisory, not enforced (§8)
signal: corpus: 3 hot entities are cold and belong in .ank/archive/entities/, with every entry about them (ank archive)
└── ank archive --dry-run lists them and moves nothing
pruned refs/ank/claims/TASK-820d259af6a7
check: ok — 1 tasks, 1 adr, 3 signal(s)
check writes, and that line is why. It is the only verb that prunes
refs/ank/claims: orphans, and completion records whose task is done or
closed on the default branch. Everything else it does is read-only, but a
verb that writes is not a verb to poll.
The new signal is the corpus noticing that three log entries are cold. An entry is cold with its subject, and their subject is a task that is done (ADR-467ce7e9cda1). The line under it is the command that answers it, and that command reads before it moves:
$ ank archive --dry-run
LOG-3b92d9719950 created (version 0 to 1, produced 4cc65f12691c)
LOG-6b0f39d7a4c1 jwt.verify removed from session.ts
LOG-f234f9b08f06 done, proof test:local/e3b0c44298fc@9c45c50
3 cold, nothing moved (--dry-run): ank archive moves them
It lists and moves nothing, which on a corpus this size is the right answer. When the move is worth making, it is a human’s decision landing by pull request: Ratifying and archiving.
A fault
A frozen field edited behind the tool is the fault a corpus is most likely to meet. Claim a task, then change its criterion in the file:
$ ank check; echo "exit $?"
error: TASK-51c2a0f6d418: done_criteria diverges from the claim (claimed 03c659e46adf, now ef485fff585a)
signal: ADR-06d29e727d24: ratified by its own author (human:marie)
signal: TASK-51c2a0f6d418: content is 3eff92338baa where the last write left 2e29f326854e: it was edited outside the CLI, which is legal and leaves no entry
signal: allowed_signers: no ratification key declared: permissions are advisory, not enforced (§8)
signal: corpus: 2 entity file(s) differ from main: this checkout does not carry the corpus the default branch does (git merge main)
signal: corpus: 3 hot entities are cold and belong in .ank/archive/entities/, with every entry about them (ank archive)
└── ank archive --dry-run lists them and moves nothing
check: 1 fault(s) — 2 tasks, 1 adr, 5 signal(s)
exit 8
A fault prints as error:, first, and the summary line counts it. The second
signal on the task is the same edit seen from the other side: its content no
longer hashes to what the last verb wrote, which alone would be legal. Nothing
refused the edit, and nothing could: the file is yours. What the edit cannot do
is make the hash recorded in the claim agree with it. The two criterion hashes
are content, so they are the same on your machine: 03c659e46adf is the
criterion as written, ef485fff585a as edited. done refuses and check exits
8 until the criterion is put back or the task is handed back with ank release --reason, which ends the freeze and leaves only the signal. Freezing is
verifiable, not defended.
What check reads beyond the files
check walks git history. It finds the ratification commit that anchors each
accepted decision by its subject, and it asks where a scope that matches nothing
went, a rename or a deletion. A checkout that carries one commit does not make
check fail: it makes it report every frozen decision unverifiable, at exit 0,
which is why a pipeline has to fetch the whole history (Running ank in
CI).
ank check <path> narrows the report to the entities whose scope covers that
path. --json carries every finding with its level, subject and message, and
it is what a script reads; the machine surface shows the
document.
Running ank in CI
Read this part before the recipes, because the recipes are the easy half. The
whole integration surface is two things: an exit code, and --json. Learn
those and you can write the pipeline for a CI system nobody here has heard of.
ank check is the verb a pipeline runs, and it has three answers a pipeline
routes on: 0 for a healthy corpus, signals included; 8 for findings,
which is the failure a pipeline exists to catch; and 9 for the environment
rather than the corpus – git too old, sh missing, the default branch
indeterminable. A pipeline that collapses 9 into “the check failed” sends
somebody to fix sound work. What the levels mean is Reading ank
check.
--json is opt-in on every verb and is what you parse. It carries no colour and
no layout, and it stays byte-for-byte what your parser already reads.
That is the contract. Everything below is the CI system’s own syntax around it.
Check out the whole history
On GitHub that is one line. ank check walks history: it reads the ratification
commit that anchors a frozen constraint, and it asks where a scope that matches
nothing went, a rename or a deletion. actions/checkout fetches one commit by
default, and a corpus read from one commit does not report itself unreadable –
it reports itself unverified, at exit 0. Measured on this repository at
8310e75: a --depth 1 clone answers
check: ok — 440 tasks, 61 adr, 728 signal(s) where the full clone of the
same commit answers 663, and 71 of those extra signals read
ratified, but no ratification commit is reachable: the freeze cannot be verified,
each noting that the history here is shallow, so where it went cannot be verified (git fetch --unshallow). Green, and every frozen constraint
unchecked, which is a worse failure than the red it replaces:
- uses: actions/checkout@v5
with:
fetch-depth: 0
Every host spells the depth its own way, and the thing to carry across is the
property rather than the key: check needs the history that reaches the
ratification commits, and a shallow checkout is silent about what it could not
read.
The recipes
A bare shell, which is the recipe the other two wrap:
#!/bin/sh
code=0
ank check || code=$?
case $code in
0) ;;
8) echo "ank check: findings, see above" >&2; exit 1 ;;
9) echo "ank check: environment unavailable, not a corpus failure" >&2; exit 2 ;;
*) echo "ank check: unexpected exit $code" >&2; exit 1 ;;
esac
GitHub Actions:
- name: ank check
run: |
code=0
ank check || code=$?
case $code in
0) ;;
8) exit 1 ;;
9) echo "::notice::ank could not run: environment"; exit 2 ;;
*) exit 1 ;;
esac
GitLab CI:
ank:check:
script:
- |
code=0
ank check || code=$?
case $code in
0) ;;
8) exit 1 ;;
9) echo "ank could not run: environment"; exit 2 ;;
*) exit 1 ;;
esac
Three vendors, one contract, and the third one is a shell script in a YAML file like the other two.
ank check || code=$? and never a bare ank check followed by case $?: a
GitHub Actions run: block is bash -e, so the bare form aborts the step on
exit 8 and the routing you wrote is never reached. The || code=$? form is what
survives set -e, which is why it is in all three rather than in the one that
needs it.
There is no --format github, and there never will be. Annotations,
folding markers and job summaries are one vendor’s protocol, and putting them in
the binary would couple the tool to a company. A pipeline that wants annotations
pipes --json into whatever produces them, which is exactly the arrangement
that lets the fourth vendor work without anybody shipping a release for it.
Anchoring a run
done records what ran on the machine that ran it. That is a local claim, and a
pipeline can anchor the same task to a run anybody can re-read
(Proof and verifiers says why that is worth
more):
ank attest <id> --proof test:<run-id> --detached
--detached writes the proof to refs/ank/proof/<id> and touches no file, so
the pipeline produces no commit: it needs no write access to the branch and
cannot race the merge. It is still a write to the remote, though, and on GitHub
that is a permission: the job that attests raises it, to the one thing it
writes, and every other job keeps contents: read.
permissions:
contents: write
Which ids. check is the work list, and nothing diffs .ank/ to build it.
A finished task with no external anchor carries the signal
done with no test proof: nothing external anchors it, so the ids are the
subjects of that finding and --json is what a pipeline reads them out of:
ids=$(ank check --json | jq -r '
.findings[]
| select(.message | startswith("done with no test proof"))
| .subject')
Fetch the proofs already written first, before that check runs.
actions/checkout fetches history and not refs/ank/*, so a job that skips
this reads a corpus in which nothing is anchored and re-attests every finished
task, on every push, forever. Measured on run 33285805350: 199 ids listed and
336 seconds spent pushing refs that were already there, against 16 genuinely
unproved in a clone that carries them.
git fetch origin '+refs/ank/proof/*:refs/ank/proof/*'
One direction and read-only. A refspec matching nothing is not an error, so a corpus with no proofs yet passes through untouched.
Run it on the default branch and on a push, and nowhere else. The signal is
gated on the task appearing done on the default branch, so on a feature branch
straight after done there is nothing to anchor and this job would build for
two minutes to be told so. A pull_request event runs on a merge commit no
branch carries, and a fork’s token cannot push a ref at all, which would fail
the job for a reason about the event rather than about the corpus:
if: >-
github.event_name == 'push' &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
The identity doing the attesting is typed like any other, and a pipeline is
process: (Claims and identity):
env:
ANK_AGENT: process:github-actions
The proof is a ref, so it has to reach the remote to be worth anything, and
because the ref is the whole of what this verb produces, a push that did not
land is a failure and not a warning: attest --detached exits 9 and names
the push to run. Nothing special is needed to notice it, which is the point:
ank attest "$id" --proof "test:$RUN_ID" --detached
--json still reports "pushed", so an integration that prefers to read the
flag reads the same fact. What it must not do is read the flag instead of the
code, because the two now say the same thing.
Attest on every run without worrying about the ref: it grows with facts and
never with runs. A fact is a proof type, the criteria hash it was attested
against and the identity attesting it, and a second run of the same fact
replaces the first, so show lists the latest run. A corpus whose proof refs
were written before that rule carries one entry per run, and check names each
such ref with the command that rewrites it, one ref by name:
ank attest <id> --compact --detached
It keeps one entry per fact, adds nothing and pushes that one ref. Never push
refs/ank/* with a wildcard to do the same: from a worktree, that force-reverts
the attestations a pipeline wrote.
This repository’s own pipeline does all of the above in its attest job, and
CI jobs and required checks maps it.
Multi-agent work
Ank is built for several agents working one repository at once, each taking a task the others can see is taken. This page is what handing the loop to an agent involves, and what running several of them actually requires. The skills that teach an agent the loop are installed by the routes in Install, and claims and identities, which do the arbitrating, are Claims and identity.
What the agent is taught
skill/SKILL.md is the contract an agent loads, and the five siblings beside it
carry a policy each – planning, drift audit, the autonomous loop, test-first
implementation, diagnosis – loaded when the activity calls for them.
One convention it carries is worth knowing before you watch an agent follow it:
.ank/ is opaque to an agent, the way .git/ is. Reading goes through
ank show, ank find and ank context; writing goes through the verbs. The CLI
knows what the files do not: the context budget, the frozen criterion, who holds
which claim. A human with an editor keeps every power they had.
What that costs a session is the frontmatter, not the page. A skill’s name
and description are always loaded, somewhere between fifty and eighty tokens
each; a body is read when its skill is invoked. Install
shows the projection per skill.
The methods a corpus uses
A task can name the sibling its work calls for with ank new task --method tdd,
and ank context prints that name beneath the criterion once the task is
claimed. A sibling that opens under a claim writes an entry saying so with
ank log --method tdd, titled with its name and kept apart from the work trace
(ADR-a8f9c603a0e7). The designation is read, never enforced, and ank skills is where it
is read: inside a corpus it prints the catalogue and then a second block. On a
corpus of three tasks – one designating tdd whose holder loaded it, one
designating diagnose whose holder never did, and one designating nothing where
tdd was loaded anyway – the block reads:
METHODS
diagnose designated 1 fired 0 undesignated 0
drift designated 0 fired 0 undesignated 0
loop designated 0 fired 0 undesignated 0
plan designated 0 fired 0 undesignated 0
tdd designated 1 fired 1 undesignated 1
designated counts the tasks naming the sibling, fired those of them carrying
its entry, and undesignated its entries on tasks naming none. ank skills --json carries the same counts as integers. The numbers are read from the
corpus, printed to whoever asks, and sent nowhere; outside a corpus the verb
prints the catalogue alone.
One agent, one working tree, one identity
The nominal case is a tree per agent, a clone or a git worktree, each on its
own branch cut fresh from the default one, and each session with an
ANK_AGENT of its own. ank status names the drift from the default branch,
and a stale base turns a green tree red elsewhere.
Several agents in one tree runs, and it is a degraded mode rather than the
design: two sessions sharing a tree and an identity share a claim instead of
arbitrating over it (Claims and identity
shows what that looks like). What arbitrates properly is a git worktree per
agent, because every worktree of a repository shares refs/ank/, so the
compare-and-swap settles them. Separate clones are arbitrated only when there is
a remote, which is what the push carries.
Take the task that cannot collide, or take none. status says what another
agent holds; claim names a live claim whose scope intersects yours and takes
the task anyway (ADR-052accd6e3b2), a fact to read and not an error to refuse;
graph shows what blocked_by orders. When nothing open is both unblocked and
clear, an idle session is cheaper than two agents rewriting one perimeter.
Parallel work and integration
The section above says who works where. This one assembles the whole run: several tasks, several agents, one change landing on the default branch.
Parallelism is derived, not declared. blocked_by is the only relation
between tasks, and it is the only thing that serializes work. Tasks whose
blockers are finished are ready together, and ank context computes that
mechanically: every open task in the perimeter, the ready ones first, ordered by
how many other tasks each would unblock. Do not serialize independent tasks
because they belong to the same change. If the order matters, that is a
blocked_by; if there is no blocked_by, the order is a fiction. ank graph
prints the DAG when you want the shape rather than the next move.
One branch per task. Each agent claims its task, works in its own tree on
its own branch, and finishes there. ank done proves the task in the working
tree it ran in: the verifiers ran against those files and the proof records
their hash. It does not prove the change merges, or that the combined system
works. Until the merge lands, the claim ref stands as a completion record, so no
other tree takes the task for free (Claims and
identity).
Integration is a task. When several tasks form one change, the whole is
verified the way the parts were: an ordinary task, blocked_by each part, with
its own criterion and its own verifiers. This is the spec’s model rather than a
workaround: blocked_by is a DAG with no rollup precisely because a parent
that completes when its children do is completion without proof, and the seam
between the parts is exactly where integration regressions live. The
integration task becomes ready when the last part finishes; whoever claims it
merges the branches, runs the combined verification, and records done like
any other task.
Where the branches meet is git’s business, and both shapes are legitimate:
- Independent tasks merge to the default branch directly. Two tasks that share nothing need no ceremony between them, and no integration task either.
- A multi-task change goes through an integration branch. Branch it off the default branch, merge each task’s branch into it, resolve conflicts there, and point the integration task’s verification at the combined result. The default branch receives one verified change instead of three partial ones.
What ank will not do. No verb creates a worktree, names a branch, or merges
one. Tasks, claims, criteria, dependencies and proofs are ank’s plane; branches,
worktrees, merges and history are git’s, and git is already good at them. The
one place the planes touch is accept, which runs on the default branch and
nowhere else.
How ank compares
Ank is not the first curated layer over a codebase. Here is where it differs, and what each comparison costs it.
Against retrieval
RAG answers what looks relevant to this query; ank context src/auth/ answers
what applies to this path. The first ranks by similarity, the second is a
set-membership test, and for a constraint that is the whole point, because a rule
that should have bound but ranked seventh did not bind, and nothing says so. No
embeddings, no index service, no re-indexing per commit.
The cost is real: somebody has to have written the constraint and its scope. Ank does not replace search over a corpus nobody curated; it replaces the wiki page that was never found.
Against an LLM-maintained wiki
Karpathy’s pattern is three layers (raw sources, a wiki the model owns, a schema file) and three operations: ingest, query, lint. Ank has that shape and differs on what the middle layer is.
A wiki page is derived from sources and can be regenerated if lost; an ADR records a choice that has none. Somebody decided opaque sessions over JWTs, and the record is the only copy, and nothing can re-ingest its way back to it. Hence hash-anchored and signed at ratification rather than rewritten, and a lint that is mechanical rather than a model re-reading for contradictions.
Against OKF
Google’s Open Knowledge Format is the closest relative, reached
independently: markdown with YAML frontmatter, no server, no SDK, identity carried
by the path, the format as the contract so producer and consumer stay swappable.
Its v0.2 trust signals are the same instinct as ank’s proofs, and ank has adopted
two outright: the actor convention and verified.
One axis diverges, deliberately on both sides. OKF tells consumers never to reject a document for what it lacks; ank rejects an unknown field. OKF optimises for knowledge crossing organisations, where a rejected document is knowledge lost; ank optimises for a criterion that cannot be quietly weakened, where an accepted-but-malformed file is a rule that silently stopped applying.
Against a process-skills workflow
Matt Pocock’s skills are the richest example: two dozen prompts covering the whole cycle, interviewing the human until the spec is precise, cutting it into tickets, driving the implementation test-first, reviewing before merge. The skill is the method, and the agent is held to it while it works.
Ank ships methods too, and holds the other end of them. Six skills travel
inside the binary: the contract every agent loads, and five siblings teaching
one activity each, plan, drift, loop, tdd and diagnose. A task may name any of
the five. ank new --method tdd writes the designation into it, ank context
names it and the sibling to load beneath the criterion once the task is
claimed, ank log --method tdd records that it fired, and ank skills counts
designations against firings per method. A name no sibling carries is refused
at exit 7, when the task is written rather than when the work starts.
What no verb does is enforce one. done never reads method: the criterion
is frozen by hash at claim, done runs the declared verifiers itself instead
of believing a report, and the proof records the route by which it arrived. A
verifier measures the tree and not the process, which is the point
(ADR-e4a5a8873fe3). An agent graded on its process learns to fake the process.
An agent graded on the tree has to change the tree.
So the method is taught and the outcome is measured, and the cost is the mirror of retrieval’s: a designation nobody honours is a line of frontmatter, and only the tree says whether the work was done. A team that wants a practice ank does not ship writes a skill for it, and ank still verifies only the outcome.
What it costs to run
Two numbers rather than an adjective. The six skills cost about 280 tokens in
every session, which is their frontmatter: the name and description a harness
keeps loaded whether or not a skill is ever invoked, the body being read only when
it is. That is 38 tokens for the contract and 41 to 57 for each sibling, counted
with cl100k_base over the six SKILL.md. And orientation is bounded at 8000
characters.
Exit codes
Every ank verb exits with one of these codes, and the code carries the meaning so a script can route without parsing output. They are stable. ank help --json publishes which verb returns which, under each verb’s refuses.
A refusal writes error[<code>]: <message> on stderr, followed by the exact command to run next; under --json it leaves stdout empty.
| Code | Name | Meaning |
|---|---|---|
| 0 | Ok | the verb answered |
| 1 | Generic | generic error: a call the parser refuses, a file the tool cannot make sense of |
| 2 | NotFound | no such entity, or a prefix matching more than one |
| 3 | Conflict | version conflict: the entity moved under the caller, redo context |
| 4 | Unavailable | the task is unavailable: held by another agent, or finished on another branch; take something else |
| 5 | Proof | a proof is missing, malformed, or of a type this act does not accept |
| 6 | Transition | the act is illegal from the state the entity is in: a frozen field diverged, a transition the state machine does not allow, or a write without the claim it needs |
| 7 | Prerequisite | a prerequisite is missing: the task is blocked, it has no done_criteria, a mandatory flag was not given, accept ran off the default branch, or the caller already holds a live claim |
| 8 | Findings | check or review found a fault; a signal alone leaves the code at 0 |
| 9 | Environment | the environment, not the work: sh or git absent, git older than 2.34, $EDITOR unset, a default branch that cannot be determined, a detached proof that never reached the remote, a directory that refuses the lock |
Two of them are the ones an agentic loop must handle: 3 means somebody moved, read again, and 4 means take something else. 6 and 7 are two codes on purpose: in 6 the state forbids what was asked; in 7 the thing asked for is legal and something it depends on is absent. 9 is not a failure of the work, and a pipeline that collapses it into “the command failed” sends somebody to fix sound code.
The file format
For anyone writing a tool that reads or writes .ank/: an editor plugin, an
exporter, a linter, a second implementation.
The format is the specification, and the CLI is a reference implementation of
it rather than a gatekeeper. Nothing here needs ank to be installed or asks
your tool to call it.
This document is not normative. Section 3 of the specification is: The data
model, one of the spec documents the specification
lists. Where the two disagree the specification is right and this page is a bug. What you will find here instead
is the mechanical half a writer has to reproduce exactly, the field order, the
emission rules and the quoting predicate, which the specification states as
properties rather than as a list, plus a pointer to the section that argues each
one.
Two things settle a disagreement in practice, in this order: the specification,
then crates/ank-core/tests/golden/, which is the suite your implementation can
run against.
Layout
.ank/
config.yml repository settings and named verifiers
allowed_signers public keys allowed to ratify (§8), versioned
entities/TASK-<hex>.md
entities/ADR-<hex>.md
entities/SPEC-<hex>.md
entities/LOG-<hex>.md one entry of the work trace, written once
archive/entities/<ID>.md the cold half, same format, read on demand, never rewritten
log/<ID>.md the previous shape of the trace, read and never written
index.db derived cache, belongs in .gitignore, never a source of truth
Flat, deliberately: attachment happens through the scope field, not through
location (§3). A file’s name is its id; nothing resolves through the directory
tree.
One directory for every kind. The kind is already in the id prefix, which is
already in the file name, so a per-kind subdirectory would state it a third time
and the only thing a third copy can do is disagree with the first two (§6). The
path is computed from the id with no lookup: every entity is at
.ank/entities/<ID>.md, whatever its kind, log entries included.
An entity’s entries are a query, not a path. They are the entities of kind
log whose about names it, so finding them means reading the directory, or
your own index, where the previous shape let you compute one address. That is the
one thing this layout gives up, and it is deliberate (§3).
The layout is fixed, not configured. A layout read from config.yml would
mean your tool has to parse the configuration before it can find a file, and the
conformance suite at the end of this page would stop being something anybody can
run against a directory.
The archive is the second root, and the only other one. .ank/archive/ holds
the cold half of the corpus at archive/entities/<ID>.md, flat and in the same
format by the same rule as above: one file per entity, the file name is the id,
readable with no parser and no tool. It is a directory rather than a packfile or
a walk of git history because either of those would put a parser, or git,
between a reader and a file.
A tool reading .ank/ needs three things from it. It is read on demand and
never by default: the listing, the prefix resolution and the load of the hot
corpus answer exactly what they answered before the archive existed, so a tool
that ignores it is still correct, only blind to the cold half. It is resolved
against both roots wherever an id is resolved, so a reference, a supersession,
a blocker or an entry’s subject naming an archived entity names something, and a
scope pointing at .ank/entities/<ID>.md for an entity since archived is not
dead; an id present in both roots is one entity, read hot. And an archived file
is never edited: it is parsed once when it arrives, the content hash recorded
then is its digest and is never updated, and a file whose bytes stop matching is
a fault rather than a change to take in. That digest is recorded in index.db
and in no file, which is the one place the cache is load-bearing; the section on
what is derived says what deleting it costs.
A writer moves files there with ank archive and never by hand; the move
commits nothing, and what the hot corpus holds is settled by a human reading a
diff.
The previous layouts, and the window for them. Corpora written before the
flat directory are at tasks/TASK-<hex>.md and adr/ADR-<hex>.md, with the log
inside the task body; corpora written between that revision and this one carry
the trace as one file per entity under .ank/log/. A reader must accept all of
them; a writer must never produce them. A corpus holding several layouts is
one corpus, and nothing in it is counted twice: if an id resolves in both, decide
and document which wins rather than silently preferring one, and an entity’s
entries are the union of the two sources. ank check reports a corpus still in a
previous shape as a signal, not a fault, naming the command that moves it: such a
corpus parses, round-trips and answers every verb.
This dual read is a window, not a feature. It exists for the release across which an existing corpus moves, and a new tool has no reason to write anything but the flat layout.
Identifiers are TASK-, ADR-, SPEC- or LOG- followed by exactly 12
hexadecimal characters, lowercase on output and accepted in either case on
input. They hash the act of creation (timestamp, identity, title, entropy)
never the content, so they survive every edit (§3). A tool that resolves short
prefixes must require at least 4 hex characters and must fail on an ambiguous
one, listing the candidates. Guessing is the one behaviour the format rules out
by name.
The shape of a file
Markdown with YAML frontmatter, UTF-8 without BOM, LF line endings:
---
<frontmatter>
---
<body>
The delimiters are exact: the file begins with ---\n, and the frontmatter ends
at the first \n---\n. Everything after that separator is the body, kept
verbatim, byte for byte. The body is free-form markdown and carries no
convention at all: the one that used to live there, the log, is an entity of its
own now.
Unknown fields are rejected, not ignored. That is what turns a typo like
priorty: into an error instead of a silent loss, and it is the reason the
schema rule in the next section exists at all.
Fields, in canonical order
Canonical form is a fixed field order, and a serializer that emits the right fields in the wrong order produces a non-canonical file. The order is not alphabetical and not negotiable; it is the one the registry declares.
A kind is declared once, as a row of a registry: the name written in type,
the id prefix, the status values, which fields are required and which optional,
and the canonical order (§3). Entity fields is that registry
printed, generated from the table the binary reads and writes with: every kind,
every field in order, its emission form, whether it is always emitted, and the
values an enum field takes. Reproduce it as data, a table your serializer walks,
rather than as one emitter per kind; the order is the single thing most easily
lost by rewriting two straight-line emitters as a generic loop, and it is what
the round-trip rests on.
An unknown kind is rejected naming the kind, not naming the first field it
happens to carry. Inside a known kind, an unknown field is still rejected. The two
refusals answer different questions: priorty: in a task is a typo, and
type: epic is a document your tool does not know how to read.
Task
A proof entry emits its own keys in order: type, ref, then tree,
criteria, verifier and via, each omitted when absent. The values type
and via take are listed with the fields. via
is the route by which the entry arrived, and its absence means the entry was
written before the field existed, never a fourth route.
A verified entry emits by, then at. Both are required in an entry that
exists at all, an entry missing either being rejected, while the list itself is
optional on every kind.
ADR
amends names the ADRs this one changes in part, between see and the
succession. It is a flow list of entity ids, omitted when empty like a
spec’s references, and it retires nothing: each ADR it names stays accepted and
binding for everything the amendment does not touch, where supersedes replaces
the whole of one (ADR-9ee76b578257). A reader resolves each entry as it resolves a
reference, following the succession to its end; an entry naming an absent
entity, a kind other than adr, or an ADR that is not accepted is a finding
there rather than a parse error here.
Spec
An ADR without its constraint, and the absence is what makes it a kind of its
own: a spec describes where an ADR binds, so nothing in it is ever injected into
an agent’s context (§3).
The anchor differs from an ADR’s in what it covers and in nothing else: a spec
has no field carrying its authority, so ratified is taken over the body and
scope together. A tool that verifies one hashes the body as it hashes a
constraint, under the normalisation below.
references names the documents and decisions this one rests on, in the position
blocked_by takes on a task: immediately after the perimeter, before the
succession. It is a flow list of entity ids and it is omitted when empty, not
written []: a task always states whether it has blockers, and a document that
cites nothing has nothing to state. A reader resolves each entry against the
corpus; what a checker then reports of it is §4’s business and not the format’s,
and an entry naming a kind other than spec or adr is a finding there rather
than a parse error here (§3).
Log entry
No status, and that is not an omission: an entry is written once and has
nothing to transition to, so the registry declares the kind without one and your
parser must not require it. version stays, and on this kind it is a detector
rather than a counter: an entry above 1 has been rewritten, which the format
says should not happen.
records marks an entry a verb wrote, not a holder. Absent, the entry is
work. The values known to this build are listed
with the fields.
edit and create share one grammar, which carries the versions the write
moved between and the hash of the content it produced. Both of these were
written by the binary:
+scope docs/** (version 2 to 3, replaced 80bdeffde4e7, produced a06b2b863b0e)
created (version 0 to 1, produced f9b82c19eaec)
edit is a change of content outside a status transition, and names the fields
it changed and the hash of the state it replaced. create is the record new
writes at birth: version 0 is the state before the file existed, and replaced
is absent because nothing was. An entry about a log entry never carries
create, since an entry is the record.
method carries neither, and that is why the grammar above is a property of
two values rather than of the field. ank log --method <name> writes it to
record that a sibling skill opened under a claim, and the entry’s title is the
name alone:
tdd
A value your reader does not know is read as machinery and never refused.
Optional fields are omitted, never emitted empty. An entity with no author
serialises without the key at all. Writing author: with nothing after it would
change every older file on its first rewrite, and the round-trip guarantee below
forbids that.
Actors
Every field naming an actor, author and the by of a verified entry, is
typed: human:<id> is a person, <producer>/<version> is an agent,
process:<id> is an automated process (§3).
A value that does not match the convention is not a parse error. Your parser
must accept it; reporting it belongs to a linter, and ank check reports the
whole pre-convention set once for the corpus rather than once per file. This is
the one place where being strict would be wrong: the convention postdates most
of the author values in an existing corpus, and a parser that refused them
would lock those files out of their own format. Write typed actors; read
anything.
Emission rules
Literal blocks carry the multi-line fields, done_criteria and
constraint. Two spaces of indent, and the chomping indicator records whether
the value ends in a newline: | when it does, |- when it does not.
done_criteria: |
Auth integration tests pass, and no reference to
jwt.verify remains in src/auth/
Flow lists carry references: blocked_by: [TASK-51c2a7f0b3d9],
verify: [auth-tests, no-jwt].
Block sequences carry scope, proof and verified, two spaces of indent:
scope:
- src/auth/**
- src/middleware/session.ts
verified:
- by: human:marie
at: 2026-07-27T09:40:00Z
Scalars are emitted bare when that is unambiguous and quoted otherwise,
conservatively: when in doubt, quote. The reference implementation emits a
scalar bare only when all of these hold: it is non-empty; it contains no
newline, no ": " and no " #"; it does not end in : or a space; it does not
begin with any of
- ? : # & * ! | > ' " % @ ` [ ] { } ,
or a space; it is not one of null, ~, true, false, yes, no; and it
does not parse as a number. Otherwise it is double-quoted, with \, " and
newline escaped.
“Parses as a number” is Rust’s str::parse::<f64>, and naming the parser is
the point: it is wider than YAML’s own number grammar, so a writer that
substitutes its own language’s parser agrees everywhere except where it matters.
Measured through the binary, these titles come back double-quoted
inf INF infinity Infinity nan NaN NAN 1e5 +3 .5 1. 007
and these come back bare
0x10 5_000 1_0 1e
The infinities and NaN, in any case, are what another parser will miss;
5_000 and 0x10 are what it may wrongly catch.
Reproducing this predicate exactly is what makes a third-party writer round-trip.
The round-trip guarantee
serialize(parse(x)) == x, byte for byte, when x is in canonical form
Valid but non-canonical input (another acceptable YAML form, superfluous quotes, CRLF) is read correctly and normalised on first rewrite (§3). That is what lets a human or a third-party tool write a file without knowing the canonical form, without making that form an authoritative variant.
Read is not the same as accepted. A file that parses but does not round-trip
is a fault under ank check, reported as non-canonical form (round-trip differs) and exiting 8. Measured by double-quoting one bare title in an
otherwise canonical file: ank show goes on printing the entity unchanged while
check turns red on it. A third-party writer that does not reproduce the
emission rules therefore writes a corpus that reads perfectly everywhere and
fails its own repository’s check.
CRLF is read, never written. A parser must accept it; a serializer must not
produce it. A ---\r\n diagnosed as “missing frontmatter” sends the reader
looking for a delimiter that is right there, so normalise line endings before
splitting, not after. ank init writes a .gitattributes carrying
.ank/** text eol=lf, because on Windows git would otherwise convert back on
every checkout what the tool has just normalised.
CRLF is the one exception to that fault. When dropping the carriage returns
leaves exactly the canonical form, the content is right and only the checkout is
wrong, so check reports a signal at exit 0 instead, naming git config core.autocrlf input. Measured on one file taken through all three states: LF
and canonical is silent, the same bytes at CRLF give the signal and exit 0, and
a genuinely non-canonical file is the fault whichever line endings it carries.
Reading a version you do not know
schema is the format version, and a tool declares a range of versions it
reads, not a single one.
The version the reference implementation writes, and the range it reads, are printed with the fields.
Version 3 carries the log leaving the entity body and the verified list with
its typed actors. The flat layout arrived in the same revision and carries no
bump of its own: it moves files, not fields, so a reader that finds the file
finds every field it already knew. Version 4 carries one field, records on a
log entry.
Both bumps are the case the field exists for, and it is the same case twice. A
reader that does not know the log has left the body opens a task file, finds no
## Log section, and shows an empty history for a task that has one, silently,
with nothing reading anywhere as an error. A reader that does not know records
drops it on the next rewrite, and an entry written to stay out of the work trace
silently rejoins it. Refusing on the version says the one true thing before
either happens.
A bump is paid for by the builds already distributed, and it is worth saying where. Every entity a verb writes carries the version that build writes, so a corpus edited by a build at 4 becomes unreadable to a build at 3 one entity at a time: the older build warns once that the schema is ahead and every listing then answers as if those entities were not there. That is the designed behaviour of the range and not a side effect of it.
Older is a promise the format keeps: every field introduced after version 1 is optional at parse time, and its absence means “written before this existed” rather than “invalid”. A corpus is never migrated by a tool that refuses to read it.
Newer is refused, and refused on the version rather than on the first field it
does not recognise. Since unknown fields are rejected, a tool that checked
only its own version would report a file one version newer as unknown field
author, and its reader would go hunting for a typo. Naming the version says
the one true thing: this file is newer than this tool. The argument is in §3.
A new kind carries no bump either, and needs none: an unknown kind is
rejected naming the kind, which is the same honest refusal by a different
mechanism, so a tool that does not know spec or log stops on that entity and
says which kind stopped it. The spec and log kinds are therefore readable at
any version in the range, and the one case no bump could reach, a reader that
opens a task file alone and looks for its entries where an older shape kept them
is covered by continuing to read that shape for one window (§6).
The log
The log is neither a section of the body nor a file per entity: an entry is an
entity. It carries the instant in created, the identity in author, the
message in title, and what it is about in about, and it is written once and
never modified. A correction is a new entry naming the one it corrects.
The line grammar has not changed, and it is now how an entry is printed
rather than how it is stored: a dash and a space, the timestamp, a space, the
identity, a space, an em dash, a space, the message. ank log prints each
entry that way, after its short id and two spaces:
LOG-9cfb - 2026-07-26T14:02:11Z claude-code/1.4.2 — jwt.verify removed from session.ts
So an entry written under either previous shape reads across unchanged and nothing about it is reinterpreted: only where it lives has moved, twice.
A message longer than a line is split across title and the body, and the
split is lossless. A message of at most 100 characters is the whole of the
title, and the body is empty — 100 itself included, so a message of exactly
that length does not split. Longer, the title runs to the last space at or
before character 100 and at or after character 50, the limit itself where there
is no such space and the first newline where one comes earlier, and the body is
a newline, the remainder verbatim, a newline.
The message is the exact concatenation of the two. The separating space belongs to the remainder, so joining inserts nothing; recovering the remainder removes exactly one newline at each end and never trims. Given this 111-character message
discrepancy: the criterion assumes merge=union is configured for the log path, and .gitattributes declares none
ank log stores
title: "discrepancy: the criterion assumes merge=union is configured for the log path, and .gitattributes"
whose value is 97 characters — the cut is the last space at or before character 100 — with the body
\n declares none\n
— a newline, the remainder, a newline. The remainder opens with the space that separated the two words, so a reader that concatenates the title with it gets the message back byte for byte: 97 + 14 = 111. A body that is not of that shape carries no remainder, and the message is the title alone.
The rule exists because the title is what every lister prints, on every kind: a
2000-character title is one enormous quoted scalar and it is printed in full
wherever entities are listed. So print the head of the message with a trailing
… when there is more, and let a reader ask for the entry itself to see the
whole. Machine output carries the whole message: a parser reads no page.
Any kind may be logged against, whether a task, an ADR or a spec, and an entity with no entries has an empty log, never an error. Do not write one to record that there is nothing to record.
Order an entity’s entries by created, then seq, then the identifier. All
three are read off the entity; none of them is the file name, the directory
order or anything else outside it.
seq exists because a timestamp is not an order. created has one-second
resolution, and writing an entry costs a few hundred milliseconds, so several
entries inside one second is the ordinary case: measured on four entries written
about one task, 12 runs of 12 put all four in the same second, and 10 of those
12 came back in the wrong order when the identifier was the only tiebreak: a
hash of the act of creation, which carries no order at all. An append-only file
carried insertion order for free; a set of files does not.
When you write an entry, set seq to one more than the highest seq you can
see on the entries already about that subject, or 0 if there are none. That
requires reading them first, which is a bounded read and the same query you need
to display them. Two writers who cannot see each other will produce the same
value; that is correct rather than broken, since they were concurrent, created
separates them when their instants differ, and the identifier settles the rest.
Never treat equal seq as a conflict, and never rewrite an entry to renumber
it.
An entry read out of one of the previous layouts takes the 0-based index of
its line in the file, which is the order that file recorded. Since created
is read first, a file whose lines contradict their own timestamps is reordered
by the timestamps: measured on the reference corpus, one file of 178 stores its
lines newest-first. The guarantee is therefore exact: across distinct instants
the timestamps order the entries, and within one instant the line order does.
Writing an entry is not a write to the entity it is about. It writes no
frontmatter there, bumps no version there, and touches no file carrying a
frozen field. An entity file changes only on a real transition. That property is
the reason the log left the body, and a tool that records an entry and rewrites
the entity has given it up.
Two entries are two files, which is why there is no merge rule for the log.
The rule that used to union log sections by timestamp is gone, and the reason
once recorded for dropping it was wrong: git does not union two appends by
itself, it conflicts on them, unless a repository configures merge=union for
the path (§7). What has been protecting the corpus all along is one file per
entity, and an entry that is an entity extends that to the trace: there is no
file for two parties to append to.
One convention lives in the message, and it is where a disproved criterion is
recorded. A message opening with discrepancy: says that the frozen
done_criteria of that task rests in part on a false premise, and states what
was measured instead (§3):
LOG-d41c - 2026-08-14T18:16:03Z claude-code/03fd — discrepancy: the criterion assumes tests/skill.rs passes untouched; two tests there read `ank help`
It is a convention on the message and never on the grammar, released: <reason>
being the same kind and older, so it costs no field, no schema bump and no
migration, and every log a corpus already holds stays valid. It changes nothing
mechanically: the criterion is untouched, its hash still anchors it, and done
still verifies against that hash. A tool that reads the log should surface such
an entry; none should ever read it as permission to accept less.
The log is a work trace, not proof: nothing authoritative is anchored in it, which is why there is no chained hash over it (§3), and which is what makes an entry written by a second party harmless. A tool may read entries and may add them; it should never reorder or rewrite one.
In a corpus in the earliest layout, the log is a ## Log section at the end of
the task body; in the one between, it is a file per entity under .ank/log/,
one line per entry. Both carry the same line grammar. Read them there; write
neither.
What is derived, and must never be stored
A file says less than the corpus does, on purpose. Four things are computed at read time and have no field:
- Blocked. A task is blocked if and only if at least one of its
blocked_byis notdone.closeddoes not unblock. There is noblockedstatus to go stale. - Reverse edges. What a task unblocks is derived by walking
blocked_byacross the corpus. A stored reverse edge is a second copy that can disagree with the first. - The claim. Never in the file. See below.
- The index.
index.dbis a cache rebuilt from the files, and every query it answers is answered again once it has been rebuilt — with one exception, which is the next paragraph.
Deleting index.db loses one thing, and it is the one thing in there that is
not derived from the files: the digest an archived file arrived with. An
archived entity is never edited, and that is enforced by comparing the file’s
bytes against the hash recorded when the index first read it. The corpus has
nowhere to keep that hash — the archived file cannot carry a digest of itself —
so it lives in the cache and nowhere else. Measured on a corpus of four archived
entries: appending a line to one of them makes ank check exit 8 naming the
file; rm .ank/index.db and the same check exits 0, on that run and on every
run after it, because the rebuild takes the changed bytes as the digest.
So “deleting it is always safe” is true of everything a reader queries and false
of archived integrity. A tool that intends to say anything about an archived
file’s bytes must treat the cache as state, and the only authority that survives
its deletion is git: git checkout -- .ank/archive/entities/<ID>.md restores the
file, where a reindex only re-blesses whatever is there.
What lives outside the files
A tool that reads only .ank/ sees the durable state and none of the
coordination. Two things are deliberately elsewhere, and both matter if your
tool intends to say anything about them.
Claims are git refs, one per task, at refs/ank/claims/<task-id>. The ref
has two states and the record it points at says which: a claim (holder,
expiry, the frozen criterion hash, the hash of applicable constraints) or a
completed record (commit, branch, identity, timestamp) written by done. A
task that is in_progress in the file with no ref behind it is simply one whose
claim expired: legal, and re-claimable (§7).
The ratification anchor is a commit message. accept writes ratified: into
the entity and produces a commit whose subject is ratify <id> and whose body
carries the anchor. The copy in the commit is the one that counts, because the
copy in the file is written by whoever writes the file. ratified cannot name
the commit, since a commit cannot contain its own identifier, so the subject is
the only pointer there is (§3).
A verifier finds it by subject, over the whole history, with no path restriction:
git rev-list --full-history HEAD
reading each commit’s subject and taking the first that is ratify <id>. The
walk is newest-first, so a decision ratified twice answers with the newest.
--full-history because path simplification exists to explain a tree’s final
state and is free to drop a commit that a merge made redundant; dropping the
ratification would report a perfectly frozen decision as unverifiable. No path
restriction because the subject is the key, and a subject is independent of
where the file sits. Measured on ank’s own repository, where ADR-01b6dd05f0db
was ratified at .ank/entities/ and later moved to .ank/archive/entities/: a
walk restricted to its current path returns no ratify commit for it at all,
where the unrestricted walk returns exactly one. A verifier that restricts by
path reports that decision unverifiable, which is the one verdict that looks
like an answer and is not.
One walk answers the whole corpus, and that is also why it is one walk: the question is asked once per decision and again for every task a decision bears on, so a search per entity is a process count that grows with the corpus.
accept also records who ran it, as a verified entry naming the typed
actor and the instant. The signature on the ratification commit says that a key
authorised the act, which is true of an agent typing under a cached passphrase as
much as of a human at a keyboard, so the entity carries the actor as well. It is
a record and not a defence: an actor value is declared and never proved, exactly
as author is, and what it buys is that an honest ratification leaves a trace a
reader can tell apart. ank check reports a decision whose ratifying actor is
its own author as a signal, never as a fault, because a solo maintainer does that
legitimately.
The key names what was hashed, and there are two because there are two kinds
that carry an anchor: constraint+scope: <hash> on an ADR, body+scope: <hash>
on a spec. A spec declares no constraint, that absence being what justifies the
kind, so the authority is carried by the whole document, and a commit claiming
constraint+scope over one would name a field the file does not have. A reader
accepts either key; a writer writes the one its kind carries.
The two hashes
Both are SHA-256 over normalised text, displayed as the first 12 hex characters, and a verifier accepts the short form or the full one.
Normalisation is what makes a hash insensitive to editing noise without ever tolerating a change of meaning: CRLF becomes LF, trailing whitespace is stripped from each line, trailing blank lines are removed.
- The criterion freeze, recorded by
claim:hash(normalize(done_criteria)). - The ratification anchor, recorded by
accept: the normalised anchored text, a newline, then eachscopeglob trimmed and followed by a newline — and then the whole buffer normalised a second time before it is hashed, which removes the newline the last glob was just given. The anchored text is an ADR’sconstraintand a spec’s body, which is the one place the two anchors differ and what the commit key above says.
That second normalisation is the entire difference between a tool that agrees
with ank accept and one that does not, so the recipe is worth writing out. The
bytes hashed are
normalize(text) + "\n" + globs.join("\n")
with no trailing newline, the globs trimmed and in the order the file lists them.
A worked vector, taken from a real accept. An ADR whose constraint is
Never Y and whose scope is the single glob src/**, ratified by the binary,
produced the commit body
constraint+scope: 33045e58af8d
and printf 'Never Y\nsrc/**' | sha256sum gives 33045e58af8d…. Keep the
trailing newline the per-glob rule appears to ask for and the same decision
hashes to b88d9f81eb08, which no ratification anywhere carries: an
implementation off by that one byte reports every ADR in the corpus as diverged.
Several globs join the same way — Rule two. over src/** and docs/**
recorded 16a85fda1d17, which is printf 'Rule two.\nsrc/**\ndocs/**'.
Freezing is verifiable, not defended (§2). Your tool can rewrite any field in any file; what it cannot do is make the recorded hash agree afterwards.
Concurrency
version is an integer incremented on every write, and it is an intra-tree
compare-and-swap: read, compare, write, under a file lock, with the write done
atomically (write-then-rename). It protects one working tree: a human and an
agent sharing a checkout. Between clones, git’s own compare-and-swap at push
time is what arbitrates (§7).
Conformance
crates/ank-core/tests/golden/ is a reusable suite, and it is small enough to
port in an afternoon:
-
valid/: every file must parse, and re-serialising it must reproduce the input byte for byte once it is in canonical form. Two fixtures are not, and they are the two shapes the format reads and never writes, so for those two the assertion is against the normalised input rather than the bytes on disk:Fixture What it is not canonical in What the comparison normalises TASK-c71f0e5a9b23.mdCRLF line endings back to LF TASK-9dd8e04b1358.mdits closing ---is the last bytethe final newline is put back Exactly one of each, and the suite asserts the counts rather than the files: a
.gitattributesthat converted the first, or an editor that added a newline to the second, would otherwise leave a green test covering nothing. The second shape can only be an entity with an empty body, since a body puts the newline after the delimiter by construction.Every version in the reader range carries a fixture, 1 through 4, and the suite fails a bump shipped without one. The old ones are there to stay: a file written before a field existed must survive a rewrite unchanged, and if one of them moves, the version bump has silently become a migration. Every kind carries one too: a log entry is an ordinary entity fixture like any other, and a fixture in the previous shape, a whole log keyed by the id of the entity it belongs to, stays for as long as that shape is read.
-
invalid/: every file must be rejected with the right error, not merely rejected. Seventeen fixtures, each naming a distinct failure:Fixture What must be refused no-frontmatter.mda file with no frontmatter at all unterminated-frontmatter.mdan opening ---with no closing onebad-id.mdan identifier that is not 12 hex characters bad-schema.mda schema outside the range, naming the version found bad-status.mda status the kind does not declare bad-glob.mda scopeentry that is not a valid globmissing-scope.mda scopewritten[]type-mismatch.mdtypedisagreeing with the id prefixunknown-field.mdan unknown field inside a known kind unknown-kind.mda kind the registry does not declare criteria-by-without-criteria.mdcriteria_bywith nodone_criteriabad-proof-via.mda viaoutside the closed setverified-without-at.mda verifiedentry withbyand noatspec-with-constraint.mda constrainton a speclog-without-about.mda log entry with no aboutlog-without-seq.mda log entry with no seqlog/bad-log-line.mda log file holding a line the grammar refuses Most of them assert on more than the error’s type, and that is where a permissive implementation is actually caught.
unknown-kindmust nameepic, not the id prefix and not the first field an unknown kind happens to carry: a reader told “invalid identifier” goes hunting for a typo in the hex.bad-schemamust name the version it found.bad-proof-via,verified-without-at,spec-with-constraint,log-without-aboutandlog-without-seqmust each name the field —spec-with-constraintnamingconstraintrather than the kind, because the field is the one a spec exists in order not to carry.bad-log-linemust name line 2: the file’s other two lines are entries the grammar accepts, so a fixture whose every line were bad would pass for a reader that gave up on the first one.The first two rows are one distinction and not two cases of one error, which is why both fixtures exist.
no-frontmatter.mdnever opens a frontmatter;unterminated-frontmatter.mdopens one and never closes it, and its refusal names the closing delimiter — unterminated frontmatter: no closing---after the opening one. Telling a reader that a file which plainly starts with---must start with---sends them looking for a delimiter that is right there, which is the same hour---\r\nalready cost. A reader that folds the two into one error passes this fixture and fails the person holding the file.One case per kind at least, or a kind ships with its strictness untested. The list grows with the format; what does not change is that a test asserting only that parsing returned an error passes for the wrong reason forever.
There is no invalid fixture for a malformed actor. That is deliberate and is the one place strictness is wrong: the convention is checked, never parsed (above).
The second half is where a permissive implementation is caught. Accepting a file the format rejects is the failure mode that spreads, because the corpus it writes still looks fine until something else reads it.
Where to go next
- The specification: each spec declares in its own body which sections it carries – §3 for the data model and canonical form, §6 for storage, §7 for the coordination plane, §8 for identity and ratification.
- Entity fields and config.yml keys: the tables, generated from the registry the binary reads with.
- The quickstart: if you also want to use the tool.
Entity fields
Every kind, with its fields in canonical order: the kind registry the binary reads and writes with, printed. The file format says what the order and the emission forms mean; this page is the table it describes.
This build writes schema 4, and reads schema 1 through 4. A file declaring a newer schema is refused on its version, never on the first field it does not recognise.
Task
type: task, ids TASK-<12 hex>.
| # | Field | Emission | Presence | Values | Notes |
|---|---|---|---|---|---|
| 1 | id | bare | always emitted | TASK-<12 hex> | |
| 2 | type | bare | always emitted | always task | |
| 3 | slug | scalar | omitted when absent | cosmetic, never resolved on | |
| 4 | title | scalar | always emitted | ||
| 5 | created | scalar | always emitted | ISO 8601, always UTC with the Z suffix | |
| 6 | author | scalar | omitted when absent | a typed actor; absent means the entity predates the field | |
| 7 | status | bare | always emitted | open | in_progress | done | closed | |
| 8 | scope | block sequence | always emitted | globs, never empty | |
| 9 | blocked_by | flow list | always emitted | task ids, [] when empty | |
| 10 | done_criteria | literal block | omitted when absent | frozen by hash at claim | |
| 11 | criteria_by | bare | omitted when absent | creator | claimer | invalid without done_criteria |
| 12 | verify | flow list | omitted when absent | verifier names config.yml declares | |
| 13 | method | scalar | omitted when absent | one sibling skill the binary carries | |
| 14 | proof | block sequence of maps | omitted when absent | keys in order: type, ref, tree, criteria, verifier, via | |
| 15 | verified | block sequence of maps | omitted when absent | readings: by, then at, both required in an entry | |
| 16 | schema | integer | always emitted | ||
| 17 | version | integer | always emitted |
ADR
type: adr, ids ADR-<12 hex>.
| # | Field | Emission | Presence | Values | Notes |
|---|---|---|---|---|---|
| 1 | id | bare | always emitted | ADR-<12 hex> | |
| 2 | type | bare | always emitted | always adr | |
| 3 | slug | scalar | omitted when absent | cosmetic, never resolved on | |
| 4 | title | scalar | always emitted | ||
| 5 | created | scalar | always emitted | ISO 8601, always UTC with the Z suffix | |
| 6 | author | scalar | omitted when absent | a typed actor; absent means the entity predates the field | |
| 7 | status | bare | always emitted | proposed | accepted | superseded | |
| 8 | scope | block sequence | always emitted | globs, never empty | |
| 9 | constraint | literal block | always emitted | binding on every scope it covers once accepted | |
| 10 | see | scalar | omitted when absent | reference code the constraint points at | |
| 11 | amends | flow list | omitted when absent | ADR ids this one changes in part | |
| 12 | supersedes | bare | omitted when absent | an entity id | |
| 13 | ratified | scalar | omitted when absent | the signed commit accept wrote | |
| 14 | verified | block sequence of maps | omitted when absent | readings: by, then at, both required in an entry | |
| 15 | schema | integer | always emitted | ||
| 16 | version | integer | always emitted |
Spec
type: spec, ids SPEC-<12 hex>.
| # | Field | Emission | Presence | Values | Notes |
|---|---|---|---|---|---|
| 1 | id | bare | always emitted | SPEC-<12 hex> | |
| 2 | type | bare | always emitted | always spec | |
| 3 | slug | scalar | omitted when absent | cosmetic, never resolved on | |
| 4 | title | scalar | always emitted | ||
| 5 | created | scalar | always emitted | ISO 8601, always UTC with the Z suffix | |
| 6 | author | scalar | omitted when absent | a typed actor; absent means the entity predates the field | |
| 7 | status | bare | always emitted | proposed | accepted | superseded | |
| 8 | scope | block sequence | always emitted | globs, never empty; what the document governs | |
| 9 | references | flow list | omitted when absent | entity ids | |
| 10 | supersedes | bare | omitted when absent | an entity id | |
| 11 | ratified | scalar | omitted when absent | the signed commit accept wrote, over the body and scope | |
| 12 | verified | block sequence of maps | omitted when absent | readings: by, then at, both required in an entry | |
| 13 | schema | integer | always emitted | ||
| 14 | version | integer | always emitted |
Log entry
type: log, ids LOG-<12 hex>.
| # | Field | Emission | Presence | Values | Notes |
|---|---|---|---|---|---|
| 1 | id | bare | always emitted | LOG-<12 hex> | |
| 2 | type | bare | always emitted | always log | |
| 3 | slug | scalar | omitted when absent | cosmetic, never resolved on | |
| 4 | title | scalar | always emitted | the message, or its head | |
| 5 | created | scalar | always emitted | ISO 8601, always UTC with the Z suffix; the instant of the entry | |
| 6 | author | scalar | omitted when absent | a typed actor; who wrote the entry | |
| 7 | scope | block sequence | always emitted | the subject’s scope as it stood | |
| 8 | about | bare | always emitted | an entity id of any kind | |
| 9 | seq | integer | always emitted | rank among that entity’s entries, from 0 | |
| 10 | records | scalar | omitted when absent | edit | create | method | absent is work; a value unknown to the reader is read as machinery |
| 11 | verified | block sequence of maps | omitted when absent | readings: by, then at, both required in an entry | |
| 12 | schema | integer | always emitted | ||
| 13 | version | integer | always emitted | above 1 means the entry was rewritten |
records
What a log entry a verb wrote records. Absent, the entry is work; a value this build does not know is read as machinery and never refused.
| Value | Records |
|---|---|
edit | a change of content outside a status transition: the fields, the versions, the hash replaced and the hash produced |
create | the creation of its subject: version 0 to 1 and the hash produced |
method | a sibling skill opened under a claim; the title is its name |
Proof type
What a proof entry’s ref points at. A weak type anchors nothing outside the agent’s reach, and check marks it.
| Value | Trust | Meaning |
|---|---|---|
test | strong | a test run, by a reference to it |
commit | strong | a commit the work is in |
human-review | weak | somebody read the work |
assertion | weak | a statement, and nothing behind it |
Proof via
The route by which a proof entry arrived. Absent means the entry was written before the field existed, never a fourth route; a test entry submitted by a caller anchors nothing outside the agent’s reach.
| Value | Meaning |
|---|---|
verifier | ank ran a verifier config.yml declares; its own statement |
attested | reached the task on refs/ank/proof/<id>, written by whoever held the pipeline |
submitted | a caller passed it to done --proof or attest --proof; recorded as given |
config.yml keys
Every key .ank/config.yml may carry, with the type of its value and what an absent key means. A key not listed here is refused, and so is a file whose schema is newer than this build reads.
The file declares schema: 1, the only version this build reads. A duration is <n><unit>, the unit one of s, m, h or d. <name> stands for a key the file chooses; ank config <key> reads and writes the scalar keys, and roles and identities are edited by hand.
| Key | Type | Default | Notes |
|---|---|---|---|
schema | integer | required | the version of this file’s format |
context_budget | integer | 8000 | what context hands a reader, in characters |
claim_ttl_max | duration | 2h | the longest lease a claim is granted, whatever --ttl asks |
claim_ttl_default | duration | 30m | the lease claim grants without --ttl, capped by claim_ttl_max |
default_branch | string | none | the branch carrying the reference state; absent, refs/remotes/origin/HEAD names it |
peers.<name> | path | none | a peer corpus a scope entry reaches by name, relative to this root or absolute |
verifiers.<name>.run | command | required | what done runs through sh; required in a declared verifier |
verifiers.<name>.timeout | duration | 10m | how long done lets the command run |
verifiers.<name>.default | boolean | false | true writes the verifier into every task ank new task creates |
roles.<name>.can | list of strings | [] | what the role may do, declared |
roles.<name>.cannot | list of strings | [] | what the role may not do, declared |
identities.<identity> | string | none | the role of an identity; one absent from the table is an agent |
weight.hot_files | integer | 3000 | check signals a hot corpus holding more entity files |
weight.plane_bytes | integer | 4000000 | check signals claim and proof records weighing more bytes |
Environment variables
Every variable the binary reads, and what it changes. None of them is required:
with the environment empty but for PATH, every verb works, and the variables
below adjust who is acting, where a file is found, and how output looks.
Every variable
| Variable | Read by | What it changes |
|---|---|---|
ANK_AGENT | every verb, and ank mcp | the identity this session acts as: who holds a claim, who wrote an entity, who ran done. Unset or blank, <user>@<hostname>, and ank mcp writes under ank-mcp/<version> |
USERNAME | every verb, with ANK_AGENT unset | the <user> of the fallback identity; the first of USERNAME, USER, LOGNAME set and not blank wins, and none gives unknown |
USER | every verb, with ANK_AGENT unset | the <user> of the fallback identity, when USERNAME gives none |
LOGNAME | every verb, with ANK_AGENT unset | the <user> of the fallback identity, when USERNAME and USER give none |
COMPUTERNAME | every verb, with ANK_AGENT unset | the <hostname> of the fallback identity, cut at its first dot and lowercased; the first of COMPUTERNAME, HOSTNAME set wins, and none asks the hostname program, then says localhost |
HOSTNAME | every verb, with ANK_AGENT unset | the <hostname> of the fallback identity, when COMPUTERNAME gives none |
ANK_UPDATE_REPOSITORY | ank update | the repository release tags are read from, in place of https://github.com/haksolot/ank; empty counts as unset |
NO_COLOR | every verb at a terminal, and ank tui | set and not empty, takes the colour and nothing else; the empty value is not an opt-out |
TERM | every verb at a terminal, and ank tui | dumb takes the colour, as NO_COLOR=1 does, and draws the structure of ank tui in ASCII; on Windows, set at all, it says the console renders escape sequences |
WT_SESSION | every verb at a Windows terminal | set, says the console renders escape sequences; with none of WT_SESSION, TERM, TERM_PROGRAM, ConEmuANSI, ANSICON set, the output is plain |
TERM_PROGRAM | every verb at a Windows terminal | set, says the console renders escape sequences |
ConEmuANSI | every verb at a Windows terminal | set, says the console renders escape sequences |
ANSICON | every verb at a Windows terminal | set, says the console renders escape sequences |
EDITOR | ank edit | the editor ank edit <id> opens when given no field to change; unset or blank, the verb refuses at exit 9 |
APPDATA | ank config --user, --repo, ank mcp, ank watch, ank tui | on Windows, the reader’s configuration directory is %APPDATA%\ank; unset, a verb that needs it refuses at exit 9 |
XDG_CONFIG_HOME | ank config --user, --repo, ank mcp, ank watch, ank tui | elsewhere than Windows, the reader’s configuration directory is $XDG_CONFIG_HOME/ank; empty counts as unset |
HOME | ank config --user, --repo, ank mcp, ank watch, ank tui | with XDG_CONFIG_HOME unset, the reader’s configuration directory is $HOME/.config/ank; neither set, a verb that needs it refuses at exit 9 |
PATH | ank done, ank skills --install, ank update | where sh and git, npx, and npm, powershell, pwsh and curl are looked for; a program found on none of its directories is refused by name |
PATHEXT | ank skills --install, ank update, on Windows | the extensions tried on PATH, in its order, of .COM, .EXE, .BAT, .CMD; unset, those four |
The test suite sets these to observe the binary. They are not an interface, and a release may change or remove any of them without notice:
| Variable | Read by | What it changes |
|---|---|---|
ANK_INDEX_BUSY_MS | every verb that opens the index | how long, in milliseconds, a connection waits on an index another process holds locked; unset, five seconds |
ANK_INDEX_STEPS | every verb that writes the index | a file the SQLite steps a refresh executed are written to |
ANK_INDEX_REFRESHED | every verb that opens the index | a file every refresh appends what it hashed and reindexed to |
ANK_TRACE_READS | every verb that reads the corpus | an absolute path every entity parse and every index opening appends a line to |
ANK_AGENT
The identity this session acts as: who holds a claim, who wrote an entity, who
ran done. Unset, it falls back to <user>@<hostname>, which carries no actor
type and makes two sessions on one machine one agent. ank status says which
one is in force and where it came from:
$ ank status
branch main
warning: no default branch, so completion refs are neither pruned nor judged (ank config default_branch <name>)
identity claude-code/opus-5+docs (ANK_AGENT)
no claim
elsewhere no claim by another agent
perimeter the whole repository, 0 constraint(s)
queue 0 proposal(s), 0 finished elsewhere
corpus 0 fault(s), 2 signal(s)
> ank context
How to write one, and why a concurrent session needs its own, is Claims and
identity. ank mcp writes under
ank-mcp/<version> unless this variable names another identity.
ANK_UPDATE_REPOSITORY
Where releases are read from. ank update is the only verb that reads it,
because it is the only verb that asks the network anything (ADR-64f32c74a0f9).
It replaces https://github.com/haksolot/ank in the git ls-remote --tags --refs the check makes, so a mirror, an internal clone or a fixture all serve.
Against a bare clone tagged v0.9.0 and v0.7.0:
$ ANK_UPDATE_REPOSITORY=/srv/ank-mirror.git ank update --check --json
{"contract":1,"current":"0.8.0","latest":"0.9.0","newer":true}
A repository carrying no tag it can parse answers "latest":null and
"newer":false, and still exits 0. What update does with the answer is
Install.
NO_COLOR and TERM
NO_COLOR takes the colour and nothing else. Colour is emitted only when
stdout is a terminal, so a pipe, a file and --json are plain already and the
variable changes nothing for a program reading them. It matters when a person
has the terminal: through a pseudo-terminal ank status came back 538 bytes
carrying 22 escape sequences, and NO_COLOR=1 448 bytes carrying none. The
empty value is deliberately not an opt-out – NO_COLOR= is how a shell spells
“unset this for the child” – and it measured 538 bytes and 22 sequences, exactly
as unset did. TERM=dumb is read the same way as NO_COLOR=1.
EDITOR
What ank edit <id> opens when it is given no field to change. It is read by
that verb alone, and its absence is an environment to repair rather than a
failure of the work:
$ env -u EDITOR ank edit TASK-b700
error[9]: EDITOR is not set, and there is no editor to open
-> EDITOR=vi ank edit TASK-b7004333d81f
Where the reader’s configuration lives
Three files belong to the person running ank rather than to any repository:
corpora.yml, the corpora the MCP server
may reach; watch.yml, what the watcher keeps warm; and
events.jsonl, the stream the watcher writes. They sit in one directory:
%APPDATA%\ank on Windows, and elsewhere $XDG_CONFIG_HOME/ank, falling back
to $HOME/.config/ank. ank watch --where prints the path it resolved: with
XDG_CONFIG_HOME=/srv/cfg it printed /srv/cfg/ank/watch.yml, and with that
variable unset and HOME=/home/me, /home/me/.config/ank/watch.yml.
With none of the three set, a verb that needs the directory refuses at exit 9 and names the variable to set.
Variables that are not an interface
Three ANK_ names appear in the source and in no table above. ANK_COMMIT,
ANK_SKILL and ANK_RELEASED_SCHEMA are read by the build, not at run time:
they are what ank --version and the schema warning print. The ANK_INDEX_
names and ANK_TRACE_READS are read at run time, which is why the table lists
them, but they exist for the test suite to observe the index and the reads, and
a release may change or remove any of them without notice.
The specification
The normative text behind every page of this site lives in the corpus, as
spec entities, and not here. A page explains and links; a spec states the rule,
and where the two disagree the spec is right and the page is a bug
(ADR-33970fcdb6e8). They argue the design; they are not a tutorial.
Each one says in its own body which sections of the original single document it
carries, so a rule that reads (§7) is resolved by the spec that claims §7. In a
checkout, ank find --type spec lists them and ank show <id> prints one whole.
On the web, each link below opens the file as the default branch has it.
- SPEC-1d5b44efd388 Intent, principles, and what v1 leaves out
- SPEC-ac4aad6d1edb The data model
- SPEC-cf285efcdca4 Storage and search
- SPEC-15a56aeedcfd Synchronisation
- SPEC-77d99d8d1ef2 Proof, anchoring and authority
- SPEC-219033e25653 The CLI surface
- SPEC-89070ce7f3b8 Presentation: structure for every reader, colour for a terminal
- SPEC-a1234da5449a The attention budget and the constraint lifecycle
- SPEC-3bccb8aee5b7 Bootstrapping, teaching and distribution
- SPEC-93531977642f Implementation, and the decisions that bound it
The list is every spec the corpus holds as accepted, and a test holds it to
ank find --type spec on this repository: a spec accepted, superseded or
retitled without this page following turns the suite red. A superseded spec is
left off, and ank show still prints it, with the one that replaced it named.
Linked, not rendered. The specs are read here through GitHub’s view of the
file rather than copied into the site at build time. A rendered copy would need
the site’s build to run ank, and would be one more place the text could be
read out of step with the corpus it came from; the link always reaches the
default branch’s bytes.
The machine surface
For someone writing a tool that reads or drives an ank corpus, who has never seen this repository: a board, an editor plugin, a dashboard, an agent harness, anything that reads a corpus and shows it to somebody. A pipeline needs less than this, and Running ank in CI is the whole of it. A client with no shell reaches the same verbs through the MCP server, and the watcher is the optional process that tells a reader a corpus moved.
What costs such a reader real time to discover is below.
The entry point is ank help --json
Not this document, and not the source. The surface describes itself, and the description is generated from the same table the binary dispatches from, so it cannot fall behind what the binary does.
$ ank help --json | cut -c1-64
{"contract":1,"verbs":[{"name":"context","usage":"ank context [<
One verb, whole, is the shape of every entry:
$ ank help close --json
{"contract":1,"verbs":[{"name":"close","usage":"ank close <id>","summary":"closes a task that will never be done; --reason is mandatory","group":"shape the work","flags":[{"name":"--reason","short":null,"takes_value":true,"repeatable":false},{"name":"--json","short":"-j","takes_value":false,"repeatable":false},{"name":"--quiet","short":"-q","takes_value":false,"repeatable":false},{"name":"--repo","short":"-r","takes_value":true,"repeatable":false},{"name":"--worktree","short":null,"takes_value":true,"repeatable":false}],"notes":["the ref is not the whole product: a push the remote refuses leaves the write standing in this clone, and the verb exits 0"],"refuses":[{"code":7,"when":"no --reason: a closure nobody explained is one nobody can reopen"},{"code":2,"when":"no such entity, or the prefix matches more than one"},{"code":1,"when":"a flag this verb does not take, or a value the parser cannot read"},{"code":3,"when":"the entity moved between the read and the write: redo context, somebody else wrote"},{"code":9,"when":"git is absent or older than 2.34: an environment to repair, not work that failed"},{"code":6,"when":"the task is already closed or already done: neither is a state this verb moves out of"}],"kinds":[],"returns":[{"when":null,"fields":[{"name":"contract","type":"number","nullable":false},{"name":"task","type":"string","nullable":false},{"name":"status","type":"string","nullable":false},{"name":"claim_revoked","type":"boolean","nullable":false}]}]}]}
So a client can discover, without reading a line of Rust: every verb, its flags and their short forms, the states it refuses on with the code each returns, and the fields of the document that comes back.
kinds is empty except where a verb writes several kinds of entity, which
today is new. There each kind names the flags it takes, the ones whose absence
it refuses and the ones another kind owns, each with its code: an ADR without
--constraint is a 7 you can read before you meet it. ank help new adr --json
is the same document narrowed to that kind, flags, notes and refusals included.
returns is a list, because a verb may answer two questions. config <key>
reads and config <key> <value> writes; log <id> reads and log <id> <message>
appends; show over a task carries the blocked_by edges and over an ADR does
not, since a document carrying them empty would be answering a question nobody
asked. Each shape names the call that returns it in its when, which is null
where the verb has only one.
returns is flat, with the path in the name. A nested field appears as
tasks followed by tasks.id and tasks.title, in the order the document emits
them. No key in any document contains a dot, so a client that wants the tree
splits on one character. The reason it is flat rather than nested is that this
document describes its own output too, and a description that recursed into
itself would not terminate.
The type vocabulary is six words: string, number, boolean, string[],
object, object[]. nullable is separate from the type and is load-bearing:
null and "" are different answers, and a client that treats a nullable string
as a string breaks on the first detached HEAD it meets.
Every document carries the contract version
"contract": 1
It leads every --json document, and it is the field to read before deciding you
can read the rest.
Within one version a document may gain a field, and may never lose, rename or retype one. So parse leniently, because an unknown field is not a breaking change and your parser must not refuse one, and treat a change of this number as the signal to look again.
It is not the version of the binary. ank --version says which build is in hand;
this says which shapes came out of it, and a release that changes no document
leaves it untouched.
The exit codes
The semantics are in the code so a caller can route without parsing output. They
are stable, and ank help --json publishes which verb returns which. The table
is the exit-code reference, generated from the enum that
declares them.
Two of them are the ones a loop must handle. 3 means “somebody moved, read again”. 4 means “take something else”.
6 and 7 are two codes on purpose. In 6 the state forbids what you asked; in 7
the thing you asked for is legal and something it depends on is absent. accept
off the default branch is a 7, because the promotion is legal and the place is
not. A client that conflates them reacts wrongly to one of the two.
9 is not a failure of the work. git absent or too old, sh missing,
$EDITOR unset, a default branch that cannot be determined. Collapsing it into
“the command failed” sends somebody to fix sound code.
1 has no reaction of its own to prescribe. It is what a mistyped command and an unparseable file both get, and a script that routes on it is guessing.
Every refusal names the exact command to run next, on stderr, and that is stable too:
$ ank show TASK-9999
error[2]: entity not found: TASK-9999
-> ank find TASK-9999
$ ank claim TASK-6da1
error[4]: TASK-6da126c832be held by tool/1.0 (expires in 30m)
-> ank context
A refusal leaves stdout empty, and a warning may not
Under --json a refusal writes nothing at all to stdout. Not an error
document, not an empty object: zero bytes. The message and its hint go to
stderr, the code goes to the exit status, and that is the whole answer.
Measured across four codes – show TASK-9999 (2), done with no claim held
(6), close with no --reason (7), accept off a default branch (9) – stdout
was 0 bytes every time. So parse stdout only once the code says 0; a parse error
on a refusal is a client reading the wrong stream.
A warning is the other case, and it does not all go to one stream. There are two kinds and the split is deliberate.
The warnings about the corpus are in the document, under a warnings array
of strings, with stderr left empty. Four verbs carry the field – context,
claim, log in its appending form, and release – and ank help --json is
where to read which, rather than this list:
$ ANK_AGENT=tool/2.0 ank claim TASK-0e61 --json
{"contract":1,"task":"TASK-0e6148ab8b03","holder":"tool/2.0","expires":"2026-09-20T18:15:44Z","warnings":["tool/1.0 holds TASK-efd813eedb23, overlapping on src/**"]}
An intersecting claim is named and never refused (ADR-052accd6e3b2), so the fact
has to reach a caller somewhere it will be read, and under --json that is the
document rather than a stream a parser was told to ignore. The array is present
and empty when there is nothing to say, so a client reads it unconditionally.
The warnings about the refs are on stderr, in both modes, because they are
not the answer: a write whose ref did not reach the remote leaves the document
and the exit code exactly as they would have been. done, release and close
each owe one. Against an unreachable remote, with stderr sent to a file of its
own, done put the document on stdout and exited 0:
$ ank done --proof commit:8db4465 --json 2>stderr.txt; echo "exit $?"
{"contract":1,"task":"TASK-277368641a6e","status":"done","commit":"8db44652564828e480ea7e5be3768b14f9c03893","branch":"main","proofs":1}
exit 0
and the warning on stderr:
$ cat stderr.txt
warning: claim not pushed: it holds in this clone only, and another clone can take the same task
So read stderr, and do not assume it is empty on success – but do not look there for what the document already carries.
The global flags
ank help --json carries every flag of every verb, so none of this is a list to
maintain by hand. Four flags are on nearly every verb, and what each one does is
worth stating once.
--json, short -j, on all 29 verbs. One line on stdout, never coloured.
--quiet, short -q, on all 29 verbs, and it empties stdout rather than
shortening it: ank check printed 340 bytes on one corpus, ank check --quiet
printed 0, and both exited 0. What is left is the exit code, which is the point
– a caller that only routes on the code pays for no output at all. It does not
silence a refusal: ank show TASK-9999 --quiet still puts error[2] on stderr
and still exits 2. --json wins over it, so --quiet --json is still a
document.
--repo <path>, short -r, on 27 verbs: which corpus. init refuses it by
name, and watch takes its corpora from its own declaration.
--worktree <path>, no short form, on every verb but watch: which tree
that corpus is anchored to. --repo says where .ank/ is; --worktree says
where a scope glob is confronted, where a path argument resolves, where a
verifier runs, and where a commit: proof is looked up (ADR-9e56318631f3). The
two are equal unless you separate them, and separating them shows: one corpus
whose single task is scoped src/**, checked against a tree that has src/,
reported 3 signals; checked against a tree that does not, 4, the extra one being
scope 'src/**' matches no file yet. That is the flag a tool wants when one
.ank/ sits above several checkouts. A path that is not a directory is refused
at exit 1, naming the confusion the refusal exists for:
$ ank status --worktree /nope/nope
error[1]: --worktree /nope/nope is not a directory
-> --worktree names the tree the corpus is anchored to, not its corpus
The remaining short forms belong to one verb or two and are read from ank help <verb> --json rather than from here: -c for --criteria, -b for
--blocked-by, -v for --verify, -p for --proof, -t and -s for
find’s --type and --status, -l for context --limit, -u for config --unset.
A task’s state is not in its file
This is the one that costs the most time, because a tool that gets it wrong under-reports silently.
The file is the entity. The state is the file together with three things that are not in it:
-
refs/ank/claims/<id>, who holds the task and until when. A claim lives in a git ref and never in the file, so two clones arbitrate through the remote rather than through a field somebody has to merge. -
refs/ank/proof/<id>, proofs a pipeline attested without making a commit. -
the log entities whose
aboutnames the task, one file per entry, stored beside the entities and not inside them:$ cat .ank/entities/LOG-c0f96bc669ae.md --- id: LOG-c0f96bc669ae type: log title: the layout is not the contract created: 2026-08-26T00:22:04Z author: tool/1.0 scope: - src/** about: TASK-6da126c832be seq: 2 schema: 4 version: 1 ---An entry carrying
recordsis machinery rather than work: written by a verb that changed the entity’s content, not by the agent holding it. This task carries two: thecreaterecordnewwrote at its birth, and anedit, because the criterion in the file below was amended before a claim froze it. The edit’s message is the whole of that accounting:$ cat .ank/entities/LOG-e6c24bc5f3e2.md --- id: LOG-e6c24bc5f3e2 type: log title: done_criteria (version 1 to 2, replaced b1f3aa97873c, produced 83947c872580) created: 2026-08-26T00:22:04Z author: tool/1.0 scope: - src/** about: TASK-6da126c832be seq: 1 records: edit schema: 4 version: 1 ---
Read the file alone and here is what you see:
$ cat .ank/entities/TASK-6da126c832be.md
---
id: TASK-6da126c832be
type: task
slug: the-parser-reads-a-corpus-without-opening-a-file
title: The parser reads a corpus without opening a file
created: 2026-08-26T00:22:04Z
author: tool/1.0
status: in_progress
scope:
- src/**
blocked_by: []
done_criteria: |
A caller reads every entity through the CLI.
criteria_by: creator
schema: 4
version: 3
---
in_progress, version: 3, and not one word about who is holding it, when the
lease expires, what they have learned, or what the two versions before this one
were. Ask the CLI instead and the same task answers whole:
$ ank show TASK-6da1 --json
{"contract":1,"id":"TASK-6da126c832be","coordination":"claimed by tool/1.0","blocked_by":[],"unblocks":[],"detached_proofs":[],"log_total":1,"log_shown":1,"log":[{"id":"LOG-c0f96bc669ae","timestamp":"2026-08-26T00:22:04Z","who":"tool/1.0","message":"the layout is not the contract","records":null}],"machinery":[{"id":"LOG-3a51d0c2b7e4","timestamp":"2026-08-26T00:22:04Z","who":"tool/1.0","message":"created (version 0 to 1, produced 5b0e7c93d1a2)","records":"create"},{"id":"LOG-e6c24bc5f3e2","timestamp":"2026-08-26T00:22:04Z","who":"tool/1.0","message":"done_criteria (version 1 to 2, replaced b1f3aa97873c, produced 83947c872580)","records":"edit"}],"content":"---\nid: TASK-6da126c832be\ntype: task\nslug: the-parser-reads-a-corpus-without-opening-a-file\ntitle: The parser reads a corpus without opening a file\ncreated: 2026-08-26T00:22:04Z\nauthor: tool/1.0\nstatus: in_progress\nscope:\n - src/**\nblocked_by: []\ndone_criteria: |\n A caller reads every entity through the CLI.\ncriteria_by: creator\nschema: 4\nversion: 3\n---\n"}
coordination came from the ref. log and machinery came from the log
entities, split on records: the work trace is what a holder wrote and is what
the budget is spent on, the machinery is what the verbs wrote and is listed
under it, so a task edited eight times does not answer “what did the last holder
learn” with eight mechanical lines. records is null on a work entry, and a
client reading log alone still sees the field. log_total and log_shown
count the work trace and never the machinery. content is the file, byte for
byte, so nothing is lost by going through the verb.
So read through the CLI, not through the directory. Not as a matter of taste:
a reader that walks .ank/ is reading one of the three sources and will report a
held task as free.
If you do read the files, whether from a viewer with no binary to call or a parser
in another language, then read all three, and read the refs correctly: most of them are in
.git/packed-refs rather than under .git/refs/, and a reader that walks only
the loose ones finds almost none of them.
ank check writes. Do not poll it
It prunes the claim refs it finds stale: orphans, and completion refs whose task
is done or closed on the default branch. The binary says so itself:
$ ank help check
ank check [<path>]
the mechanical invariants: parse, round-trip, references, frozen fields, orphaned claims; prunes the claim refs it finds stale, so it writes
global: -j, --json -q, --quiet -r, --repo <v> --worktree <v>
note: exit 8 means findings; a signal alone leaves it 0
the only verb that prunes refs/ank/claims: orphans, and completion refs whose task is done or closed on the default branch
refuses: the path names nothing inside this repository (1)
the corpus carries at least one fault; a signal alone leaves the code at 0 (8)
A dashboard refreshing every thirty seconds must not call it. ank status and
ank find are what a poll uses; check is the verb a human or a pipeline runs
deliberately.
ank show is not a poll either, and for a different reason: it renews a
claim when its subject is the task the caller holds, and so does context.
A screen nobody is sitting at would keep an abandoned claim alive all night, and
every other agent would go on reading the task as held. Which verbs move the
lease, measured, is Claims and identity.
Poll status and find; call context and show when somebody is actually
working.
Two planes, and only one of them is precious. What check prunes is the
coordination plane, the refs that say who holds what, and losing a ref
there loses a fact nothing else carries. Separately, every verb that reads the
corpus may write a disposable one: a SQLite index beside the files, which
stores a content hash per file and reindexes whatever diverged when it is opened.
That is why an entity edited by hand or arrived through git checkout shows up
on the next read with no reindex command to forget. Deleting that index is always
safe, and it is never the source of truth. But it does mean a “read” verb
touches the disk, which is worth knowing before you point twenty pollers at one
working tree.
It is also one of the two verbs that walk git history, review being the other
and sharing the same inspection, to say where a dead scope went. That makes both
of them slower than a read, and it is a second reason not to put either on a
timer. Only check prunes, so only check writes; but neither is a poll.
What a finding means, fault or signal, is Reading ank check. Under
--json each one carries its level, its subject and its message:
$ ank check --json
{"contract":1,"faults":0,"signals":4,"tasks":1,"adr":1,"hot_files":6,"plane_bytes":173,"pruned":[],"findings":[{"level":"signal","subject":"ADR-57715ae64348","message":"written by an agent and read by no human","note":[],"charge":[]},{"level":"signal","subject":"TASK-6da126c832be","message":"written by an agent and read by no human","note":[],"charge":[]},{"level":"signal","subject":"allowed_signers","message":"no ratification key declared: permissions are advisory, not enforced (§8)","note":[],"charge":[]},{"level":"signal","subject":"coordination","message":"default branch indeterminable, completion refs neither pruned nor judged (ank config default_branch <name>)","note":[],"charge":[]}]}
The conformance suite is offered to you
Two sets of fixtures in this repository are yours to reuse. The first says so in
its own header (“any third-party tool that claims to read or write the format
can reuse the tests/golden/ directory”) and the second is offered here, which
is the only place it is said:
crates/ank-core/tests/golden/: the file format. Valid files that must round-trip byte for byte in canonical form, and invalid ones with the error each must produce. If you are writing a parser in another language, this is what tells you it is right, and the file format is what it is checking against.crates/ank-cli/tests/golden-json/: the machine surface. One fixture per document the CLI returns, captured from the process rather than from a function, so what they pin is what leaves the binary. If you are writing a client, these are the exact bytes to write it against.
Both are plain files in a public repository. Copy them into your own suite; a shape that changes here without its fixture changing is a failing test on our side, which is what makes them worth copying.
What binds and what does not
- Bind to
--json, never to the human output. One line, stdout only, never coloured, and a refusal leaves stdout empty rather than putting a shape there your parser has to tell apart. Warnings split: the ones about the corpus are awarningsarray inside that document, the ones about a ref that did not reach the remote are on stderr in both modes. - Bind to the exit code, never to the wording of an error. The message and the hint are written for a person to read and may be improved; the code is the contract.
- Bind to
ank help --jsonfor what a verb accepts and returns, rather than to a list you maintain. A list maintained by hand is a list that will disagree, and the disagreement surfaces on your side, days later, as a bug you cannot see from there. - One corpus is addressed at a time, by
--repo <path>on the CLI and by thecorpusargument over MCP, so a tool holding several addresses each on its own. One process may hold several; nothing merges them. Claims are per repository, and nothing merges the claim spaces of two clones, becauserefs/ank/*cannot carry such an arbitration. - Do not poll a verb that renews a claim.
contextandshowover the held task move the lease;statusandfinddo not, and they are what a refresh is for. - Do not bind to
ank watch, and bind toevents.jsonlonly as the watcher says.
The MCP server
A client that has no shell reaches ank through ank mcp, a verb of the one
executable every route installs (ADR-1ea31c2f3c5a). There is no second file to
fetch, sign or discover: what the CLI dispatches is what the surface serves,
because they are the same file. It speaks JSON-RPC over stdio and the client
spawns it, which means it is configured rather than started.
Configuring a client
Three configurations, and each of them is pasted rather than derived.
Claude Code, one line, which writes the entry for you:
claude mcp add ank -- ank mcp --repo /path/to/your/repo
or .mcp.json at the root of the repository, which is the form that travels
with the tree and reaches everyone who clones it:
{
"mcpServers": {
"ank": {
"command": "ank",
"args": ["mcp", "--repo", "/path/to/your/repo"]
}
}
}
Claude Desktop, in claude_desktop_config.json (~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on Windows), which has no project
directory of its own and so has nothing else to go on:
{
"mcpServers": {
"ank": {
"command": "ank",
"args": ["mcp", "--repo", "/path/to/your/repo"]
}
}
}
Cursor, in .cursor/mcp.json beside the repository, or ~/.cursor/mcp.json
for every project at once:
{
"mcpServers": {
"ank": {
"command": "ank",
"args": ["mcp", "--repo", "/path/to/your/repo"]
}
}
}
command is ank and mcp is the first argument, in all three. If you
have a configuration written against ank-mcp, that is the one line to change:
releases up to 0.6.0 placed a second executable by that name and no route places
one any more, so a client still naming it gets command not found rather than a
wrong answer.
--repo is written out in all three, and that is the point of showing them.
A client spawns the server in whatever directory it happens to be in, and with
no --repo the server takes that directory. The failure mode is not an error:
it is a process quietly speaking for a corpus nobody meant, or for none. The
configuration is also where it is named rather than something a call may
override; a call that tries is refused, below. A path with no corpus under it is
refused before any client is listening, rather than after, so it reaches a
person rather than a log:
$ ank mcp --repo /tmp
error[1]: no .ank/ found from /tmp
-> ank init
Several repositories, one server
Several repositories do not need several servers. --repo names the corpus
a call naming none of its own goes to; every other corpus that server may reach
is declared once, outside every repository, and the configuration above does
not change by a character. The declaration is written through the CLI, keyed on
the repository identity of the corpus and never on a path:
$ ank config --user corpora.bccc32d77d8a9a329f772f789dc5fb1054259d70 /srv/back
corpora.bccc32d77d8a9a329f772f789dc5fb1054259d70 /srv/back
What that writes is corpora.yml (ADR-96174f1ac2b7), in the reader’s
configuration directory (Environment variables
says where that is):
schema: 1
corpora:
bccc32d77d8a9a329f772f789dc5fb1054259d70: /srv/back
The identity is the root commit, which ank status --json prints under
"corpus", so run that in the repository you want to declare and paste what it
gives you.
What the surface is
Four properties of it are load-bearing, and none of them is visible from a tool list.
Every verb COMMANDS carries, generated from that table. Not a curated
subset, under any protocol (ADR-fd98f4bc6dea). It is the same table ank help --json is generated from, walked: the summary becomes the tool description, the
refusals and their exit codes are written into it so a client can read what a
call will refuse before making it, and the flags become the input schema. One
tool per verb, whatever the table carries, named ank_<verb> because a bare
context collides with every other server a client has loaded and ank context
is not a legal tool name. Positionals arrive as arguments, an array of strings,
exactly as they sit on the command line; flags arrive under their own names with
the leading dashes stripped. What a call gets back is the document --json
returns, with the exit code beside it. Nothing in the server names a verb, so
the two surfaces cannot disagree about what exists.
One process may speak for several corpora, and never for a merged one.
--repo is resolved once, at startup, and that corpus is where a call naming
none of its own goes, so a client that never passes the argument sees exactly
what it saw before the argument existed. Every tool also carries an optional
corpus argument (ADR-fd98f4bc6dea), whose value is the repository identity of
ADR-621a7fd96ce1 – the root commit, never a path. One server, addressed at one
corpus at startup, answering out of another the reader declared:
--> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ank_find","arguments":{"arguments":["--status","open"],"corpus":"bccc32d77d8a9a329f772f789dc5fb1054259d70"}}}
<-- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"contract\":1,\"corpus\":\"bccc32d77d8a9a329f772f789dc5fb1054259d70\",\"total\":1,\"shown\":1,\"hidden\":0,\"results\":[{\"id\":\"TASK-6a3615347674\",\"kind\":\"task\",\"status\":\"open\",\"state\":\"open\",\"title\":\"The back answers a query\",\"created\":\"2026-08-26T00:22:04Z\",\"archived\":false}]}"}],"isError":false,"exitCode":0}}
That permits multiplexing. It still forbids merging, and telling those two
apart is the whole of the decision, so it is worth being exact about which one
you are building. Every call becomes ank --repo <one corpus> <verb> --json,
one corpus at a time. There is no merged claim space, no claim held on a
client’s behalf, and no arbitration across clones, because refs/ank/* is per
repository and cannot carry one – the same ban federation gets
(ADR-a1de673043b4), carried into the multi-corpus clause in the same words.
Two claims taken through one server land in refs/ank/claims of two
repositories, and neither corpus carries a word about the other’s task. So a
board over four repositories is one server and four corpora addressed on their
own, presented together by whatever sits above them; it is not four claim
spaces made into one, and a client that shows them as one list must not
arbitrate over that list. What a multi-corpus server does acquire is one
identity holding a lease in several corpora at once, and nothing beyond it.
The reachable set is declared, and nothing is discovered: the startup corpus
plus whatever corpora.yml declares. A caller cannot name a corpus by path, so
there is no spelling of “every corpus on this machine”; and an identity nobody
declared is refused by name, with nothing spawned and no falling back to the
corpus the client did not ask for:
<-- {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"error[9]: no corpus is declared under 0000000000000000000000000000000000000000, and this server reaches no corpus nobody declared\n -> ank config --user corpora.0000000000000000000000000000000000000000 <path>"}],"isError":true,"exitCode":9,"stderr":"error[9]: no corpus is declared under 0000000000000000000000000000000000000000, and this server reaches no corpus nobody declared\n -> ank config --user corpora.0000000000000000000000000000000000000000 <path>"}}
<-- {"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"error[9]: '/srv/back' is not a repository identity\n -> a corpus is named by its root commit, never a path, a remote or a slug: ank status --json prints it under \"corpus\""}],"isError":true,"exitCode":9,"stderr":"error[9]: '/srv/back' is not a repository identity\n -> a corpus is named by its root commit, never a path, a remote or a slug: ank status --json prints it under \"corpus\""}}
Both are 9, and 9 is the right code for both: what is missing is a declaration in the reader’s configuration, not anything in either corpus.
The three flags the server keeps for itself stay refused. A call that passes
--repo, --json or --quiet is turned away by name rather than being allowed
to contradict the process it is talking to, and --repo is turned away naming
the argument a caller reaches for instead:
<-- {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"--repo belongs to the server: name a corpus with the corpus argument, by the identity ank status --json prints, never by a path"}}
Nothing is hidden by that and nothing is curated: every verb takes exactly the arguments the table gives it, plus the one argument that says which corpus it runs in.
A refusal is the CLI’s refusal, and it carries the CLI’s exit code. The
surface spawns ank; it does not link it. So a refusal on state is not
re-derived here, it is inherited, hint and all, and it comes back as a result
rather than as a protocol error, because the request was well formed and the
answer is no:
<-- {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"error[2]: entity not found: TASK-9999\n -> ank find TASK-9999"}],"isError":true,"exitCode":2,"stderr":"error[2]: entity not found: TASK-9999\n -> ank find TASK-9999"}}
exitCode is present on every call including a successful one, so a client that
branches on it never has to tell absence from zero; stderr is carried
separately for the reason warnings live there in the first place. The two error
channels stay apart: a JSON-RPC error means the request was wrong, a result
with isError means the corpus said no. A client that conflates them reports
its own bug as a state of your repository.
No claim is taken that the CLI would not have taken in that clone. Every
claim goes to refs/ank/claims/<id> in that repository – the corpus the call
named, or the startup one where it named none – arbitrated by the same
compare-and-swap against the same remote. The server holds no claim on a
client’s behalf, renews none for anybody, and pools no clients under one
identity: one stdio server serves one client, so one process is one caller. It
writes under a typed process identity, ank-mcp/<version>, unless $ANK_AGENT
names one, so a deployment that already names its agents keeps naming them.
accept is a tool here like every other verb, because a generated surface
curates nothing out. Being reachable over a protocol changes nothing about it:
it still refuses off the default branch, with no way around it.
What a document, an exit code and a warning mean is the same here as on the command line, and the machine surface is where it is said.
The watcher
ank watch is a background process that keeps the derived index of the corpora
you declare current, so the ank you run finds a cache it does not have to
rebuild. It is a verb of the same one executable every route installs
(ADR-1ea31c2f3c5a), so every installation already has it – and running one is
still nobody’s condition for anything, which is the statement below about
nothing depending on it. Everything else worth knowing about it as an
integrator is what it refuses to be (ADR-4b45f344344f).
It keeps a cache warm, and answers nothing
It is not a surface. No socket, no protocol, no query of its own, and no
subset of the verbs. There is nothing here to ask: a caller that wants an answer
runs the CLI, or talks to ank mcp. A watcher answering the three
questions a dashboard finds convenient would be the curated subset
ADR-fd98f4bc6dea refuses, reached from the other direction, and it would be a
third dispatch path in a project that has spent its history reducing to one. It
does tell you when a corpus it watches changes, on a stream described below,
and that is push and never pull: it says what moved, it says nothing about what
moved, and there is still nothing to connect to.
Nothing depends on it. Every verb gives the same output and the same exit
code with it stopped; its absence is never an error, and no installation route
makes running it a condition of using ank. The installation without a watcher is
the one every CI runner, every container and every agent has, so it is the
normal one, made slower rather than made lesser. Stopping it is always safe, and
stopping_the_daemon_changes_no_verbs_output_and_no_verbs_exit_code in its
suite is what keeps that true.
Nothing it serves is believed over the files. The index is a cache the CLI
rebuilds from a content hash per .ank/ file at read time, so a listing off a
warm index and a listing off no index are the same bytes. The watcher does not
compute that listing and holds no copy of it: it spawns ank and asks for a
read, which is what leaves the index current. It is a cache warmer, so a poll it
misses costs latency and never correctness.
It watches what you declared, and looks for nothing. The declaration is
watch.yml, beside the corpora.yml of ADR-96174f1ac2b7 in the reader’s
configuration directory (Environment
variables says where).
It lives outside every repository, and it is keyed on the repository identity of
ADR-621a7fd96ce1 rather than on a path:
schema: 1
# Seconds between two mirrors of refs/ank/claims/*. Optional; 60 when omitted.
fetch: 60
watch:
# The key is the root commit, which `ank status --json` prints under
# "corpus". One checkout, or a list of them.
4f0b8c2d1e6a39572c84ab0d6f31e75c9a2b48d0: /home/me/work/ank
9c31ea77b04d5f2681ac3e095b7d4f60a8213ce5: /home/me/work/other
Two worktrees of one repository are two paths under one key, and therefore one
watched corpus – which is the whole reason the key is not the path. A key that
is not a root commit is refused by name, a checkout filed under another
repository’s identity is refused with both identities, and a directory carrying
no .ank/ is refused rather than searched around: ank watch --list prints
what would be watched without watching anything, and ank watch --where prints
where the declaration is read from.
The only things it writes into a repository are that repository’s own
index.db and a mirror of refs/ank/claims/*. The mirror lands in
refs/ank/watch/origin/claims/*, a tracking namespace of the watcher’s own, and
carries the remote’s claims alone: a mirrored proof is read by nobody, so none
is fetched (ADR-4b45f344344f). No branch, no tag, no working tree, no index of
git’s, and no refs/ank/claims. It takes no claim, holds none on anybody’s
behalf, and renews none – a claim is renewed by working, not by reporting
(ADR-0bb7ea8991bc). A fetch that fails is a line on stderr and never an exit
code: the watcher keeps watching, and a dead network downgrades what it offers
rather than stopping it.
What the mirror buys is one line of ank status. refs/ank/claims/* in a
clone is whatever somebody last fetched by hand, so on a parc of clones the
elsewhere section reports who held what an hour ago and has no way to say so.
status reads the mirror beside its own plane and reports both as one list,
with the local record winning wherever they carry the same task. No other verb
reads it, and none may: an installation with a watcher and one without have to
be one product. That is asserted rather than promised, in
a_claim_a_watcher_mirrored_is_reported_by_status_and_by_nothing_else, which
compares every listing verb byte for byte with the mirror present and absent.
A change becomes an event, and the stream is yours to follow
The watcher appends a line when a corpus it watches changes, and any program may follow it. That is the one thing it offers a consumer, and it is offered as a file rather than as a connection: there is nothing to bind to, nothing to negotiate, and nothing you can ask it. Several readers follow the same bytes without the watcher knowing any of them exist.
Where it is. events.jsonl, beside watch.yml in the same directory. One
file for every corpus the watcher was handed; each line says which corpus it is
about.
What a line is. One JSON object, one line, newline-terminated:
{"schema":1,"corpus":"<root commit>","change":"entities"}
{"schema":1,"corpus":"<root commit>","change":"refs"}
schemais the shape of the line, and it is not the contract version that--jsondocuments carry: the two move for different reasons. Within a schema a line may gain a field and may never lose, rename or retype one. Skip a line whose schema you do not know rather than guessing at it.corpusis the repository identity of the watched corpus – the root commit, whichank status --jsonprints under"corpus". Never a path, and no path is carried beside it: a corpus reached by two paths is one corpus, and a field naming one would be an invitation to key on it. Two checkouts of one corpus changing produce two lines carrying the same identity, and the answer to both is the same one read.changesays what moved.entitiesis “a file under that corpus’s.ank/was written, added or removed”;refsis “the watcher’s mirror of the remote’srefs/ank/*moved”, which is how a claim taken in a clone you cannot see reaches you. The vocabulary is closed at those two today and may gain a word.
What a line is not. It carries no title, no status, no body, no identifier
and no entity content of any kind, and it never will: an event that carried the
new state of a task would save you a call and would make the watcher a source of
corpus data that nothing generated from the verb table ever validated
(ADR-4b45f344344f). What changed is on the stream; what is now true of it is what
the CLI answers, and no_event_carries_entity_content_a_reader_would_get_from_the_cli
asserts the absence rather than promising it. An event also never says what to do
about itself. There is one sensible thing to do, which is to read the corpus
again, and the stream does not presume to say so.
How to follow it. Open the file, remember the offset you have read to, and read the bytes past it whenever you like. Three rules and they are the whole protocol:
- Consume whole lines only. The watcher writes one line per call, but a reader that took a half-written one would repaint on a corpus it could not name.
- If the file is shorter than your offset, the watcher started it over and you read from the beginning again. The stream is news and not a log: nothing is anchored in it, nothing hashes over it, so it is bounded rather than kept, and what you missed while you were not running is missed whatever the bound is.
- If the file is not there, no watcher has ever run for this reader. That is not an error and not a degraded mode: read the corpus when your person asks, as every installation without a watcher does. If it appears later, follow it from its beginning.
What it does not license. Following the stream is not a second way into the
corpus, and it must not become one. ank tui follows it and still reaches every
byte it shows by running the CLI with --json, because the event says a corpus
moved and nothing more. And an event is a repaint, never a write: the reader
answers one by running status and find, and deliberately not show, which
renews the lease when the id is the task the caller holds (ADR-0bb7ea8991bc). A
screen nobody is sitting at is told the corpus changed all night and keeps
nobody’s claim alive; an_event_repaints_the_list_and_renews_no_claim is what
holds that true.
What to bind to
- Do not bind to
ank watch. It answers nothing, and it is optional by construction. Write your integration against the CLI or the protocol surface, and let the watcher make those answers arrive sooner where somebody chose to run one. - You may bind to
events.jsonl, and it is a narrow license: it tells you a corpus changed so you can stop asking on a timer. Every answer still comes from the CLI, and your integration has to work with no stream at all, because most installations have none.
Ratifying and archiving
Two acts decide what the corpus is, and both are a human’s: ank accept makes a
proposed decision binding, and ank archive moves what is cold out of the hot
corpus. Both land on the default branch by pull request, like every other
change. This page is the recipe for each on this repository, where main is
protected and takes no direct push.
Ratifying a decision
ank accept is the one act ank commits for, and it runs on the default branch
only, with no flag around it (ADR-6d8736c04cfa). A constraint ratified on a
feature branch would bind on that branch alone, which is a constraint of variable
geometry and a ratification hash that depends on where it is read.
That rule is about where you stand when you sign. It is not a licence to push to
main. accept writes a commit and stops; it never pushes. So the ratification
commit reaches main through a pull request like every other change, and CI sees
it before it lands:
git switch main && git pull
ank accept <id> # the gate is satisfied here
git branch ratify/<id> # branch first, at the ratification commit
git reset --hard origin/main # local main back where it was
git push -u origin ratify/<id>
gh pr create --fill --base main --head ratify/<id>
gh pr merge ratify/<id> --merge # a merge commit, and nothing else
git switch main && git pull
Branch before resetting. The commit is then held by a ref, and a botched ordering is a reflog recovery rather than a lost signature.
Both of the last two gh lines name the branch, and neither naming is
decoration. Nothing in this sequence ever switches to ratify/<id>: git branch creates it without moving, and git push -u origin ratify/<id> pushes a
branch you are not standing on. So the shell is still on main when gh runs,
and gh resolves a pull request from the current branch.
A bare gh pr create --fill therefore reads main as the head, finds it is also
the base, and refuses with “head branch is the same as base branch”. A bare gh pr merge --merge looks for the pull request whose head is main, finds none,
and exits 1 with no pull requests found for branch "main" – with the
ratification sitting unmerged on a branch, which is the worse of the two because
it looks like the sequence ran. Naming the branch in both is what makes this work
from where it leaves you.
Merge with a merge commit, never a squash and never a rebase. This is
load-bearing and not a matter of taste. A ratification is located by the subject
of its commit, ratify <id>, walked with rev-list --full-history and no path
restriction (the file format has the
walk), so any strategy that preserves the commit preserves the anchor:
- a merge commit keeps the subject, the SHA and the signature, and
ank checkverifies the ratification exactly as if it had been committed in place; - a squash rewrites the subject to the pull request title, so the anchor is
never found again.
checkreports the entity as unverifiable, which is a signal and exit 0: the corpus quietly stops being verifiable while CI stays green; - a rebase keeps the subject, so the anchor is still found, but it replays the
commit without its signature. In a corpus that signs, and this one does,
checkreports that as a fault (ADR-964be4d940b2 makes signing a regime the corpus is in, so an unsigned corpus survives a rebase and this one would not).
Both are disabled on the repository and in its ruleset, and the ruleset has no
bypass actors: a maintainer cannot merge around it either. If the repository ever
requires branches to be up to date before merging, set the update method to merge
for the same reason. Which key signs, and how check judges the signature, is
Signing keys.
accept refuses a supersession while any tracked file outside .ank/ still cites
the document it retires (ADR-3b6ba766a42e). Re-point those citations first, in
their own change: the refusal names every site with its line, and there is no
bypass.
Archiving what is cold
ank archive moves what is cold into .ank/archive/entities/: superseded
documents, and every entry whose subject is cold, meaning a superseded document
or a task done on the default branch (ADR-467ce7e9cda1). check names the verb
in one signal when the hot corpus holds any of it. Like accept, it decides what
the corpus is, so a human runs it and the result lands by pull request. Unlike
accept, it commits nothing: it renames files and stops, and the commit is
yours.
git switch main && git pull
git switch -c archive/<date>
ank archive --dry-run # read the list: this is what moves
ank archive # the same list, moved
ank check # green: references, blockers and scopes resolve into the archive
git add -A .ank # git sees each file as a rename
git commit -m "archive what is cold"
git push -u origin archive/<date>
gh pr create --fill --base main --head archive/<date>
gh pr merge --merge
git switch -c archive/<date> puts you on the branch, so here gh resolves the
pull request from where you stand and the bare gh pr merge works.
Run it from a branch cut from the default branch and level with it: an entry is
cold when its task is done on the default branch, which is what the verb reads,
so a local default branch that is behind leaves entries hot that could have
moved. An archived file is never edited afterwards; check verifies it against
the digest it arrived with and reports a changed one as a fault.
Releasing
A release is a tag. release.yml publishes on a pushed v* tag and on nothing
else, because a release that could be produced from a branch would make “the
binary for v0.8.0” a question rather than an answer. Everything a release ships
– the archive names, the npm packages, the release title – derives from the
tag. What makes that safe is that the tree has to agree with the tag before
anything is built.
Bump the version literals
Ten literals across seven files carry the version, and every one of them must say the version you are about to tag:
crates/ank-cli/Cargo.toml version = "<v>"
crates/ank-core/Cargo.toml version = "<v>"
npm/ank/package.json "version"
npm/ank/package.json the three optionalDependencies pins
npm/ank-linux-x64-musl/package.json "version"
npm/ank-darwin-arm64/package.json "version"
npm/ank-win32-x64/package.json "version"
.claude-plugin/plugin.json "version"
You do not have to remember that list. The check that gates the release prints it, with the exact line that repairs each literal, when you hand it a version the tree does not carry:
$ bash .github/scripts/check-version.sh 0.8.1
version 0.8.1 disagrees with 10 of 10 version literals
...
repair every line above, then move the tag:
crates/ank-cli/Cargo.toml: version = "0.8.1"
crates/ank-core/Cargo.toml: version = "0.8.1"
npm pkg set version=0.8.1 --prefix npm/ank
...
and answers version 0.8.1 agrees with all 10 version literals once they are
bumped. Run it before you tag. The bump lands like any change, by pull request,
and Cargo.lock follows the two manifests on the next build.
The same script runs on every pull request against fixture trees, as the
version check job, so a bump that touched six of the seven files is red on the
branch that made it and not on the tag (CI jobs and required
checks).
Rehearse with a dispatch
release.yml also runs on workflow_dispatch, and a dispatch builds the same
four targets and publishes nothing. Run one before tagging whenever the pipeline
itself changed:
gh workflow run release.yml --ref main
The alternative is discovering that the release pipeline is broken at the moment you use it, on a tag you then have to delete, and a tag is the one thing here that is awkward to take back. On a dispatch the version job says there is no tag to compare and skips the comparison, the four builds and the three npm smoke tests run in full, and the two publish jobs do not start.
Tag
With the literals bumped and merged, tag the merge commit on main and push the
tag. The tags so far are annotated, with the version as the first line of the
message and what the release adds under it:
git switch main && git pull
git tag -a v<version>
git push origin v<version>
What the run then does, in order:
- version compares the tag with every literal and refuses before anything is built. A check at the end would already have spent the matrix.
- build runs the tests and packages one archive per target:
x86_64-unknown-linux-musl,aarch64-apple-darwin,x86_64-apple-darwinon an Intel runner, andx86_64-pc-windows-msvc, each with a.sha256. - npm smoke installs the assembled packages from their tarballs on three platforms and checks the wrapper answers like the binary.
- publish creates the GitHub release from the four archives, only once every build row passed. A release carrying three targets out of four would look complete.
- publish-npm publishes the three platform packages and then the wrapper, only once all three smoke tests agree.
A version with a prerelease suffix, v0.9.0-rc1, publishes under the npm
dist-tag next and as a GitHub prerelease, so it is installable by whoever asks
for it by name and never becomes what a bare npm install -g @haksolot/ank
resolves to. Anything else publishes as latest.
NPM_TOKEN
publish-npm authenticates with the repository secret NPM_TOKEN, and it is
the only credential outside the run that the pipeline uses: the GitHub release
is created under the workflow’s own token. It has to be an npm token allowed to
publish every package under the @haksolot scope, and a missing or expired one
fails publish-npm alone – the GitHub release still publishes, because the
release page is the primary channel and npm a convenience on top of it. Rotate
it under the repository’s Settings → Secrets and variables → Actions, and
re-run the failed job rather than the tag.
After the release
ank update --check against the new tag should report it as latest, and the
installers read the release page directly, so both routes serve it as soon as
the release exists. Nothing else is published: no package manager ships ank
(Install says why).
CI jobs and required checks
Four workflows run on this repository. Three of them run on every pull request
and on every push to main; the fourth runs on a tag.
The required checks
The ruleset on main requires six checks by name, and a pull request cannot
merge until each of them is green:
ubuntu-latest the three gates, on Linux
macos-latest the three gates, on macOS
windows-latest the three gates, on Windows
version check / ubuntu-latest release.yml's version check, against its fixtures
msrv / ubuntu-latest the workspace builds on the declared MSRV
msrv is tight / ubuntu-latest the minor below it does not
The three gates are the ones CONTRIBUTING.md asks you to run locally: cargo fmt --check, cargo test --workspace, and ank check on the repository’s own
corpus. The ruleset also allows the merge commit and nothing else, and has no
bypass actors; Ratifying and archiving says
why the merge method is load-bearing.
ci.yml
-
test, a matrix overubuntu-latest,macos-latestandwindows-latest, and the three required checks named after them. It checks out the whole history, becauseank checkwalks it, and runs the three gates. The Linux leg keeps the binary it built forattest. -
attest / ubuntu-latestruns on a push tomainonly, after the matrix. It anchors every taskcheckreports asdone with no test proofto this run, withank attest --proof test:<run-id> --detached, and it is the only job in the file that holdscontents: write. It turns red rather than skipping when the proof does not reach the remote. The recipe it follows is Anchoring a run. -
version check / ubuntu-latestruns.github/scripts/check-version-fixtures.sh, the release’s version check exercised against fixture trees on every pull request instead of only on a tag (Releasing). It has no local equivalent other than running the script:bash .github/scripts/check-version-fixtures.sh -
msrv, a three-platform matrix building on the declared minimum Rust version. Only its ubuntu leg is required, because the floor is one number for the workspace; the other two legs run and report, and they are what would catch a floor that differed per target. -
msrv is tight / ubuntu-latestrequires the minor below the floor to fail. Both MSRV jobs are The MSRV.
Pull request runs superseded by a new push are cancelled. Runs on main are
queued and never cancelled, because two attest jobs racing on
refs/ank/proof/* would turn an intermediate merge red while the head of main
is green.
docs.yml
Builds this site with mdBook, pinned by version and by the digest of its release
archive, on every pull request, and checks that every internal link and fragment
resolves in the built site. A push to main also deploys it to GitHub Pages; a
pull request never does. It is not a required check, and a red build still
blocks nothing but the site.
install.yml
Tests install.sh and install.ps1 against the published release, on the
platforms each claims to install, including macos-15-intel, which no other
workflow carries. It asserts the two things neither installer may ever do:
unpack an archive whose hash does not match, and end in silence on a platform it
has no archive for. It reads the release page and writes nothing.
release.yml
Runs on a pushed v* tag, and on workflow_dispatch as a rehearsal that builds
and publishes nothing. Its jobs are version, build, npm-smoke, publish
and publish-npm, and Releasing walks them in order.
The MSRV
The minimum supported Rust version is measured, never chosen. It is the oldest toolchain the workspace builds on, found by walking toolchains upward against the tree, and it moves when a dependency moves it.
Where it is declared
rust-version is declared in every crate of the workspace, six manifests
carrying the same number:
crates/ank-contract/Cargo.toml
crates/ank-core/Cargo.toml
crates/ank-cli/Cargo.toml
crates/ank-mcp/Cargo.toml
crates/ank-daemon/Cargo.toml
crates/ank-tui/Cargo.toml
What enforces it
Two CI jobs, both required on main (CI jobs and required checks):
msrv builds on the declared toolchain, proving it is sufficient, and msrv is tight requires the minor below it to fail, proving it is not higher than the
tree needs. Both read the number out of a manifest rather than carrying it, so
neither job is edited when the floor moves.
They read two of the six, crates/ank-cli/Cargo.toml and
crates/ank-core/Cargo.toml, and msrv fails when those two disagree. Nothing
compares the other four, so moving the floor means moving all six by hand, and
the two directions fail differently. A manifest left above the new floor is
caught, because msrv builds on the declared toolchain without
--ignore-rust-version and cargo refuses the package outright, with rustc <running> is not supported by the following package. One left below it is
caught by nothing, and goes on declaring a floor the workspace no longer has.
Moving it
Never edit that number to make a build pass. The floor is a consequence of a dependency, not a target held on purpose, and re-measuring means re-running the walk, one toolchain at a time from the current floor upward:
cargo +<toolchain> build --workspace --locked --ignore-rust-version
The first toolchain that builds is the floor. Write it into all six manifests,
in one change, and let both jobs confirm it: msrv that it builds, msrv is tight that the one below does not.
An unexpectedly successful build on an older toolchain names a number to lower. It does not lower it. If a job goes red here, read the diagnostic and open an issue or a task; the walk is what decides, and a human runs the walk.
Signing keys
A ratification is a commit, and a signature is what makes it answerable to
somebody. Signing is a regime the corpus is in, not a precondition of ratifying
(ADR-964be4d940b2): ank accept produces a signed ratification commit where the
repository is configured to sign, and an unsigned one where it is not, and it
never refuses for want of a key. What changes is what ank check can say about
the result. The rules it applies, and the four outcomes it keeps apart, are
section 8 of the specification, in Proof, anchoring and
authority; this page is how to set a repository up so they
apply.
Until a key is declared
A corpus that declares no key says so, once, as a signal on allowed_signers:
no ratification key declared: permissions are advisory, not enforced (§8).
That is one of the lines the quickstart ends with. It is honest rather
than wrong: every ratification is still anchored by its hash, and nothing
pretends a signature was checked. Declaring a key is what turns it into a check.
Sign your commits
The set-up is git’s. With an SSH key, which git has signed with since 2.34:
git config gpg.format ssh
git config user.signingkey ~/.ssh/id_ed25519.pub
With OpenPGP, gpg.format stays at its default and user.signingkey names the
key id. accept signs whenever the repository can: when user.signingkey is
set, or commit.gpgsign is true. It does not wait for commit.gpgsign,
because a maintainer who signs selectively still means a ratification to be
signed, so whether your ordinary commits are signed is yours to decide; check
only ever judges the ratification commits.
Declare the key in .ank/allowed_signers
.ank/allowed_signers lists the keys allowed to ratify, one per line, in git’s
allowed-signers layout: a principal, then the key type, then the key.
marie@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
sean@example.com gpg 0123456789ABCDEF0123456789ABCDEF01234567
The key type names the format. An ssh-* entry is the public key itself; a
gpg entry is the full fingerprint or the long key id. Lines starting with #
are comments, and this repository’s own file uses them to record why each key
was added.
The file is versioned like any other, so adding a key is a diff in review, and
it lands on main by pull request before the key it declares can ratify
anything there.
Who reads the file depends on the format. ank check reads it itself and
judges whichever key signed against it, for both formats. git’s own git verify-commit reads it only under gpg.format = ssh with
gpg.ssh.allowedSignersFile pointed at it; under OpenPGP it resolves the
signature through the keyring and never opens this file. So the file is
load-bearing for ank either way, and for git only if you point git at it.
What check says afterwards
Once a key is declared, check judges every ratification commit against the
file and keeps four outcomes apart, which section 8 of the specification lists
with the reason for each. The one to expect on a machine that is not yours is
the signal and not the fault: a CI runner without your public key is a correct
repository on an incomplete machine, and check says it could not verify rather
than either failing or passing it.
A ratification merged by squash or rebase loses what this page set up; the merge method that keeps it is Ratifying and archiving.
Re-recording the demo
The README opens on a recording of the loop: ank context, ank graph, a claim,
the code change, and ank done. It is a function of three scripts in
assets/demo/, so a retake is a re-run and not a performance, and it goes stale
whenever the output it shows changes.
assets/demo/setup.sh builds the demo repository the recording is made in
assets/demo/play.sh what the recording plays, command by command
assets/demo/render.sh renders the cast to the two images the README shows
assets/demo/demo.cast the last recording, as asciicast v2
What the scripts expect
They were written for one machine and say so in their first lines, rather than taking arguments:
- the repository checked out at
~/ank, and built withcargo build --release, because bothsetup.shandplay.shrun~/ank/target/release/ank; ~/demofree:setup.shdeletes and rebuilds it, and writes a throwaway SSH signing key to~/demo-signso the ratification in the recording is signed;- agg on the
PATHor in~/.cargo/bin, and JetBrains Mono in~/fontsor in the directoryANK_DEMO_FONT_DIRnames.render.shrefuses at exit 9 and prints the install command when either is missing, rather than falling back to another font without saying so.
Retaking it
cargo build --release
bash assets/demo/setup.sh
asciinema rec --cols 100 --rows 34 -c "bash assets/demo/play.sh" assets/demo/demo.cast
bash assets/demo/render.sh assets/demo/demo.cast assets
setup.sh ends by printing ank graph and ank context on the fresh demo
repository; read them before recording, because they are the state the
recording starts from. The cast is recorded at 100 by 34, the size the current
one declares. render.sh writes assets/demo.gif in Catppuccin Latte and
assets/demo-dark.gif in Mocha, which the README picks between with
prefers-color-scheme.
GIF and not SVG, and that was measured. An SVG rendering of a cast animates
when the SVG is inlined into a page and not when it is loaded through an
<img>, which is the only way a README can embed it, so it showed an empty
terminal. play.sh keeps to the eight ANSI colours for the same kind of
reason: a 256-colour escape would come out of the terminal’s palette instead of
the theme render.sh hands agg.
Commit the cast and both images together, and update the image’s alt text in
the README if what the recording shows changed.
Project conventions
The rules a change to this repository is held to beyond the three gates in
CONTRIBUTING.md.
The binding ones are ratified ADRs, and ank context <path> serves them in full
for the files you are about to touch; this page names where each one lives
rather than restating it.
Working the loop on this repository
The development plan lives in .ank/, and it is worked with the same loop
the quickstart teaches: ank context, ank claim, ank log,
ank done. The corpus is reached through the CLI and never by opening its files
(ADR-e45e1a29fe91): ank show <id> gives an entity whole, ank find lists,
ank context binds, and scope, graph, status, review, check and the
read form of log answer the rest. That ADR enumerates every route on each
side, because a short list gets read as the whole one. It constrains agents, not
people: a human with an editor keeps every power they had, and ank check
remains what notices.
A task here declares its verifiers when it is written, and a plain ank new task already carries cargo-test and fmt-check, which .ank/config.yml
marks default (Proof and verifiers).
Changing the format
The format is the specification, and ank-core is its reference
implementation. Every format change happens in this order, and it is ratified
(ADR-63b59c5c26f7):
- the specification: the
specdocument that states the rule, which for a format change is The data model (the specification). No field exists in the code without existing there first. - the goldens:
crates/ank-core/tests/golden/.valid/must round-trip byte for byte once normalised,invalid/must be rejected with the expected error. - the code.
The round-trip is byte-identical on canonical form; valid but non-canonical input is read correctly and normalised on first rewrite. CRLF is read, never written, and one golden is in CRLF on purpose and must come back in LF.
Documentation
Documentation a person reads lives in docs/, changes by pull request, and is
published as this site (ADR-33970fcdb6e8). A block presented as what ank prints
is replayed against the binary by crates/ank-cli/tests/doc_replay.rs, and a
reference table is generated from the source table it describes
(ADR-2b62b9a1fe67): the exit codes, the entity fields and the config.yml keys
each carry the command that regenerates them in their first lines. A normative
rule is linked, never restated.
Testing
A criterion that talks about the binary is tested through the binary. When a
done_criteria says “the binary does X”, the test invokes the binary, not only
the function meant to produce X. Two real defects shipped past green unit tests
that way. The same rule applies to platforms: OS-dependent behaviour is not
verified until it has run on all three.
English only
English is the only language of the project (ADR-d3a8dcf38817): prose, identifiers, comments, CLI output, error messages, entity titles, bodies, slugs and log entries. Non-English text is a finding, not a matter of taste. The one exception is a string whose meaning is its literal value: an external proof reference, a quoted third-party message, a fixture asserting a byte sequence.
Style
- Self-correcting errors. Every refusal prints the exact command to run next, never generic help.
- Terse output, in the shape of
git status.--jsoneverywhere, strictly opt-in, and never colored. - No emojis in messages, documentation or comments.
- No new dependency without necessity. A static binary is the goal.