Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 under sh -c on 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 show over 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 check exit 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 check says 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: done lives in the file, so on your branch alone until the merge. The claim ref became a completion record at done, 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.

CodeNameMeaning
0Okthe verb answered
1Genericgeneric error: a call the parser refuses, a file the tool cannot make sense of
2NotFoundno such entity, or a prefix matching more than one
3Conflictversion conflict: the entity moved under the caller, redo context
4Unavailablethe task is unavailable: held by another agent, or finished on another branch; take something else
5Proofa proof is missing, malformed, or of a type this act does not accept
6Transitionthe 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
7Prerequisitea 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
8Findingscheck or review found a fault; a signal alone leaves the code at 0
9Environmentthe 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_by is not done. closed does not unblock. There is no blocked status to go stale.
  • Reverse edges. What a task unblocks is derived by walking blocked_by across 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.db is 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 each scope glob 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’s constraint and 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:

    FixtureWhat it is not canonical inWhat the comparison normalises
    TASK-c71f0e5a9b23.mdCRLF line endingsback 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 .gitattributes that 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:

    FixtureWhat must be refused
    no-frontmatter.mda file with no frontmatter at all
    unterminated-frontmatter.mdan opening --- with no closing one
    bad-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 scope entry that is not a valid glob
    missing-scope.mda scope written []
    type-mismatch.mdtype disagreeing with the id prefix
    unknown-field.mdan unknown field inside a known kind
    unknown-kind.mda kind the registry does not declare
    criteria-by-without-criteria.mdcriteria_by with no done_criteria
    bad-proof-via.mda via outside the closed set
    verified-without-at.mda verified entry with by and no at
    spec-with-constraint.mda constraint on a spec
    log-without-about.mda log entry with no about
    log-without-seq.mda log entry with no seq
    log/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-kind must name epic, 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-schema must name the version it found. bad-proof-via, verified-without-at, spec-with-constraint, log-without-about and log-without-seq must each name the field — spec-with-constraint naming constraint rather than the kind, because the field is the one a spec exists in order not to carry. bad-log-line must 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.md never opens a frontmatter; unterminated-frontmatter.md opens 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\n already 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>.

#FieldEmissionPresenceValuesNotes
1idbarealways emittedTASK-<12 hex>
2typebarealways emittedalways task
3slugscalaromitted when absentcosmetic, never resolved on
4titlescalaralways emitted
5createdscalaralways emittedISO 8601, always UTC with the Z suffix
6authorscalaromitted when absenta typed actor; absent means the entity predates the field
7statusbarealways emittedopen | in_progress | done | closed
8scopeblock sequencealways emittedglobs, never empty
9blocked_byflow listalways emittedtask ids, [] when empty
10done_criterialiteral blockomitted when absentfrozen by hash at claim
11criteria_bybareomitted when absentcreator | claimerinvalid without done_criteria
12verifyflow listomitted when absentverifier names config.yml declares
13methodscalaromitted when absentone sibling skill the binary carries
14proofblock sequence of mapsomitted when absentkeys in order: type, ref, tree, criteria, verifier, via
15verifiedblock sequence of mapsomitted when absentreadings: by, then at, both required in an entry
16schemaintegeralways emitted
17versionintegeralways emitted

ADR

type: adr, ids ADR-<12 hex>.

#FieldEmissionPresenceValuesNotes
1idbarealways emittedADR-<12 hex>
2typebarealways emittedalways adr
3slugscalaromitted when absentcosmetic, never resolved on
4titlescalaralways emitted
5createdscalaralways emittedISO 8601, always UTC with the Z suffix
6authorscalaromitted when absenta typed actor; absent means the entity predates the field
7statusbarealways emittedproposed | accepted | superseded
8scopeblock sequencealways emittedglobs, never empty
9constraintliteral blockalways emittedbinding on every scope it covers once accepted
10seescalaromitted when absentreference code the constraint points at
11amendsflow listomitted when absentADR ids this one changes in part
12supersedesbareomitted when absentan entity id
13ratifiedscalaromitted when absentthe signed commit accept wrote
14verifiedblock sequence of mapsomitted when absentreadings: by, then at, both required in an entry
15schemaintegeralways emitted
16versionintegeralways emitted

Spec

type: spec, ids SPEC-<12 hex>.

#FieldEmissionPresenceValuesNotes
1idbarealways emittedSPEC-<12 hex>
2typebarealways emittedalways spec
3slugscalaromitted when absentcosmetic, never resolved on
4titlescalaralways emitted
5createdscalaralways emittedISO 8601, always UTC with the Z suffix
6authorscalaromitted when absenta typed actor; absent means the entity predates the field
7statusbarealways emittedproposed | accepted | superseded
8scopeblock sequencealways emittedglobs, never empty; what the document governs
9referencesflow listomitted when absententity ids
10supersedesbareomitted when absentan entity id
11ratifiedscalaromitted when absentthe signed commit accept wrote, over the body and scope
12verifiedblock sequence of mapsomitted when absentreadings: by, then at, both required in an entry
13schemaintegeralways emitted
14versionintegeralways emitted

Log entry

type: log, ids LOG-<12 hex>.

#FieldEmissionPresenceValuesNotes
1idbarealways emittedLOG-<12 hex>
2typebarealways emittedalways log
3slugscalaromitted when absentcosmetic, never resolved on
4titlescalaralways emittedthe message, or its head
5createdscalaralways emittedISO 8601, always UTC with the Z suffix; the instant of the entry
6authorscalaromitted when absenta typed actor; who wrote the entry
7scopeblock sequencealways emittedthe subject’s scope as it stood
8aboutbarealways emittedan entity id of any kind
9seqintegeralways emittedrank among that entity’s entries, from 0
10recordsscalaromitted when absentedit | create | methodabsent is work; a value unknown to the reader is read as machinery
11verifiedblock sequence of mapsomitted when absentreadings: by, then at, both required in an entry
12schemaintegeralways emitted
13versionintegeralways emittedabove 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.

ValueRecords
edita change of content outside a status transition: the fields, the versions, the hash replaced and the hash produced
createthe creation of its subject: version 0 to 1 and the hash produced
methoda 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.

ValueTrustMeaning
teststronga test run, by a reference to it
commitstronga commit the work is in
human-reviewweaksomebody read the work
assertionweaka 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.

ValueMeaning
verifierank ran a verifier config.yml declares; its own statement
attestedreached the task on refs/ank/proof/<id>, written by whoever held the pipeline
submitteda 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.

KeyTypeDefaultNotes
schemaintegerrequiredthe version of this file’s format
context_budgetinteger8000what context hands a reader, in characters
claim_ttl_maxduration2hthe longest lease a claim is granted, whatever --ttl asks
claim_ttl_defaultduration30mthe lease claim grants without --ttl, capped by claim_ttl_max
default_branchstringnonethe branch carrying the reference state; absent, refs/remotes/origin/HEAD names it
peers.<name>pathnonea peer corpus a scope entry reaches by name, relative to this root or absolute
verifiers.<name>.runcommandrequiredwhat done runs through sh; required in a declared verifier
verifiers.<name>.timeoutduration10mhow long done lets the command run
verifiers.<name>.defaultbooleanfalsetrue writes the verifier into every task ank new task creates
roles.<name>.canlist of strings[]what the role may do, declared
roles.<name>.cannotlist of strings[]what the role may not do, declared
identities.<identity>stringnonethe role of an identity; one absent from the table is an agent
weight.hot_filesinteger3000check signals a hot corpus holding more entity files
weight.plane_bytesinteger4000000check 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

VariableRead byWhat it changes
ANK_AGENTevery verb, and ank mcpthe 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>
USERNAMEevery verb, with ANK_AGENT unsetthe <user> of the fallback identity; the first of USERNAME, USER, LOGNAME set and not blank wins, and none gives unknown
USERevery verb, with ANK_AGENT unsetthe <user> of the fallback identity, when USERNAME gives none
LOGNAMEevery verb, with ANK_AGENT unsetthe <user> of the fallback identity, when USERNAME and USER give none
COMPUTERNAMEevery verb, with ANK_AGENT unsetthe <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
HOSTNAMEevery verb, with ANK_AGENT unsetthe <hostname> of the fallback identity, when COMPUTERNAME gives none
ANK_UPDATE_REPOSITORYank updatethe repository release tags are read from, in place of https://github.com/haksolot/ank; empty counts as unset
NO_COLORevery verb at a terminal, and ank tuiset and not empty, takes the colour and nothing else; the empty value is not an opt-out
TERMevery verb at a terminal, and ank tuidumb 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_SESSIONevery verb at a Windows terminalset, says the console renders escape sequences; with none of WT_SESSION, TERM, TERM_PROGRAM, ConEmuANSI, ANSICON set, the output is plain
TERM_PROGRAMevery verb at a Windows terminalset, says the console renders escape sequences
ConEmuANSIevery verb at a Windows terminalset, says the console renders escape sequences
ANSICONevery verb at a Windows terminalset, says the console renders escape sequences
EDITORank editthe editor ank edit <id> opens when given no field to change; unset or blank, the verb refuses at exit 9
APPDATAank config --user, --repo, ank mcp, ank watch, ank tuion Windows, the reader’s configuration directory is %APPDATA%\ank; unset, a verb that needs it refuses at exit 9
XDG_CONFIG_HOMEank config --user, --repo, ank mcp, ank watch, ank tuielsewhere than Windows, the reader’s configuration directory is $XDG_CONFIG_HOME/ank; empty counts as unset
HOMEank config --user, --repo, ank mcp, ank watch, ank tuiwith XDG_CONFIG_HOME unset, the reader’s configuration directory is $HOME/.config/ank; neither set, a verb that needs it refuses at exit 9
PATHank done, ank skills --install, ank updatewhere 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
PATHEXTank skills --install, ank update, on Windowsthe 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:

VariableRead byWhat it changes
ANK_INDEX_BUSY_MSevery verb that opens the indexhow long, in milliseconds, a connection waits on an index another process holds locked; unset, five seconds
ANK_INDEX_STEPSevery verb that writes the indexa file the SQLite steps a refresh executed are written to
ANK_INDEX_REFRESHEDevery verb that opens the indexa file every refresh appends what it hashed and reindexed to
ANK_TRACE_READSevery verb that reads the corpusan 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.

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 about names 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 records is machinery rather than work: written by a verb that changed the entity’s content, not by the agent holding it. This task carries two: the create record new wrote at its birth, and an edit, 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 a warnings array 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 --json for 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 the corpus argument 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, because refs/ank/* cannot carry such an arbitration.
  • Do not poll a verb that renews a claim. context and show over the held task move the lease; status and find do not, and they are what a refresh is for.
  • Do not bind to ank watch, and bind to events.jsonl only 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"}
  • schema is the shape of the line, and it is not the contract version that --json documents 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.
  • corpus is the repository identity of the watched corpus – the root commit, which ank status --json prints 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.
  • change says what moved. entities is “a file under that corpus’s .ank/ was written, added or removed”; refs is “the watcher’s mirror of the remote’s refs/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 check verifies 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. check reports 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, check reports 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:

  1. version compares the tag with every literal and refuses before anything is built. A check at the end would already have spent the matrix.
  2. build runs the tests and packages one archive per target: x86_64-unknown-linux-musl, aarch64-apple-darwin, x86_64-apple-darwin on an Intel runner, and x86_64-pc-windows-msvc, each with a .sha256.
  3. npm smoke installs the assembled packages from their tarballs on three platforms and checks the wrapper answers like the binary.
  4. 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.
  5. 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 over ubuntu-latest, macos-latest and windows-latest, and the three required checks named after them. It checks out the whole history, because ank check walks it, and runs the three gates. The Linux leg keeps the binary it built for attest.

  • attest / ubuntu-latest runs on a push to main only, after the matrix. It anchors every task check reports as done with no test proof to this run, with ank attest --proof test:<run-id> --detached, and it is the only job in the file that holds contents: 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-latest runs .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-latest requires 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 with cargo build --release, because both setup.sh and play.sh run ~/ank/target/release/ank;
  • ~/demo free: setup.sh deletes and rebuilds it, and writes a throwaway SSH signing key to ~/demo-sign so the ratification in the recording is signed;
  • agg on the PATH or in ~/.cargo/bin, and JetBrains Mono in ~/fonts or in the directory ANK_DEMO_FONT_DIR names. render.sh refuses 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):

  1. the specification: the spec document 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.
  2. the goldens: crates/ank-core/tests/golden/. valid/ must round-trip byte for byte once normalised, invalid/ must be rejected with the expected error.
  3. 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. --json everywhere, strictly opt-in, and never colored.
  • No emojis in messages, documentation or comments.
  • No new dependency without necessity. A static binary is the goal.