hird
You run Claude Code in one terminal, Codex CLI in another, maybe Copilot in your editor. They cannot see each other's work, and nothing either of them learns survives the session. hird is one local binary that gives them a shared queue and a shared memory, in one SQLite file, with a terminal UI over the top.
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Claude Code│ │ Codex CLI │ │ Copilot │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ stdio MCP │ stdio MCP │ stdio MCP
┌─────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│ hird mcp │ │ hird mcp │ │ hird mcp │ one process per session
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
└───────────────┼────────────────┘
┌─────▼──────┐ ┌──────────┐
│ SQLite │◄───────┤ hird tui │
│ (WAL) │◄───────┤ hird CLI │
└────────────┘ └──────────┘
Install
curl -fsSL https://raw.githubusercontent.com/aoprisan/hird/main/scripts/get.sh | sh
No Rust toolchain: this downloads the
release binary for
your platform (Linux x86_64/aarch64, macOS Apple silicon — the
Linux ones are static musl builds that run on any distribution), verifies
it against the release's checksum, and installs it as
~/.local/bin/hird. Append -s -- --install-skill
to the sh to also install the agent skill, and set
HIRD_VERSION=v0.2.2 to pin a release instead of taking the
latest.
With a Rust toolchain (1.88 or newer),
cargo install hird --locked gets the same binary from
crates.io, and from a checkout
./scripts/install.sh --install-skill builds the release
binary, copies that standalone snapshot to ~/.local/bin/hird,
then removes the release build artifacts; re-run it after upgrading the
source.
Whichever way it arrived, --install-skill installs the bundled
portable skill for Codex and OpenCode (~/.agents/skills/hird),
Claude Code (~/.claude/skills/hird), GitHub Copilot
(~/.copilot/skills/hird), and the Gemini CLI
(~/.gemini/skills/hird). It is optional, can be run
separately (hird --install-skill), and new agent sessions
should be started after installing it.
SQLite is compiled in, so there is nothing else to install and nothing to
configure before first use. The database is created on first write at
${XDG_DATA_HOME:-~/.local/share}/hird/hird.db; ask for the
exact path with hird db-path.
Dispatching work
There are two ways to get work to an agent, and they are not exclusive — the same queue serves both, at the same time, and you can mix them task by task.
By hand, by number
You file a task, then tell a specific agent "pick up task 42". It
calls task_claim on the number you said. You decide who works
what and when.
Letting agents pull
You file a plan, then tell every agent "work the queue". Each one
calls task_next and is handed the most important task that is
actually workable. Nobody assigns anything.
Self-service dispatch was added alongside manual dispatch, not in place of
it. Nothing in hird ever pushes work at an agent: task_next is
a tool an agent chooses to call, so an agent you never tell to work the
queue will sit idle until you name a number. Naming a number still works
exactly as it did — including for tasks that were filed as part of a
dependency graph.
By hand, by number
File the task from the shell (or with a in the TUI):
hird add "Port the config loader to serde" \
--body "Keep the env-var precedence. Tests in tests/config.rs must pass."
# → 1
hird ls
# #1 open Port the config loader to serde
hird add prints the task number and nothing else, so it composes:
seq=$(hird add "…"). Then, in any
registered harness, say:
pick up task 1
The agent calls task_claim with seq: 1, gets the full
body back, works it, and calls task_complete. Watch it happen with
hird tui, or read the history afterwards with
hird show 1.
A claim is atomic. If you hand the same number to two agents, exactly one wins and the loser gets a sentence it can repeat to you verbatim:
task 42 is claimed by codex:9f2c until 2026-07-25T14:32:00.000Z
A claim on a task whose dependencies are unfinished is refused for that reason instead — see Dependencies. Those are the only two ways a claim by number fails.
Letting agents pull
Naming a number works, but it does not scale past one agent. Give the queue a plan instead, and let the agents take it apart themselves.
hird add "Design the storage schema" --path 'src/db.rs'
hird add "Port the repository layer" --path 'src/repo/**' --needs 1
hird add "Rewrite the renderer" --path 'src/tui/**'
Now open three harnesses and tell each one the same thing:
"work the queue". Each agent calls task_next, and
the queue hands out the most important task that is actually workable
— open, with every dependency finished, and whose files nobody else is inside.
Tasks 1 and 3 go out immediately, in parallel. Task 2 waits, because the schema
is not done yet.
$ hird graph
wave 1 (workable now)
#1 in_progress Design the storage schema
#3 claimed Rewrite the renderer
wave 2 (after wave 1)
#2 open Port the repository layer waits for #1
Agents keep calling task_next as they finish, until it comes back
with nothing to do — and it says why it is idle: everything done, everything
blocked, or everything left overlapping live work.
The plan as a file
Three hird add calls are fine. A plan worth reviewing before you
point three agents at it is worth writing down, and a plan you will edit is
worth being able to run twice.
# plan.toml
plan = "serde-migration"
[[task]]
name = "schema"
title = "Design the storage schema"
priority = 3
paths = ["src/db.rs"]
[[task]]
name = "repos"
title = "Port the repository layer"
paths = ["src/repo/**"]
needs = ["schema"]
hird plan apply plan.toml --dry-run # read it first
hird plan apply plan.toml # file the whole graph in one transaction
Tasks carry names instead of numbers, so the file means the same thing in a fresh database as in one holding two hundred tasks, and it can live in the repository beside the code it describes. Each task is remembered under the name it was filed with, so applying an edited plan files only what the file has gained — the tasks already in flight keep their claims, their history and their numbers. Either the whole graph lands or none of it does.
What a plan may say is exactly what a task row can hold: a title, a body, a
priority, the files it expects to touch, the tasks it waits for. There are no
conditionals, no loops and no retries, and that is not an omission to be
filled in later — hird hands work out because an agent asked for
it, and a file that could say when to run something would be
describing a scheduler this queue deliberately does not have.
--dry-run is the reason the file earns its place.
It lays the plan out in the waves hird graph will print, and then
tells you the thing the queue could not have told you until the work was
already live:
$ hird plan apply plan.toml --dry-run
plan "serde-migration" — 5 tasks, 3 waves, at most 3 at once
wave 1 (workable now)
new schema Design the storage schema
files src/db.rs
new renderer Rewrite the renderer
files src/tui/**
new audit Audit the renderer tests
files src/tui/view.rs
…
same files, nothing ordering them — the queue hands these out one at a time,
so the waves above are wider than the work really is
renderer and audit — src/tui/** overlaps src/tui/view.rs
declaring no files: notes
the queue cannot keep another agent out of what these touch, and what
earlier work learned reaches them by title alone
nothing was written; drop --dry-run to file it
Two globs can be intersected before either file exists, so a pair that looks parallel and will in fact be handed out one at a time is knowable while the plan is still a file. Wave 1 above says three; the work says two and a queue.
Filing a plan is a human act, which is why there is no MCP tool for it: the
review is the point, and an agent that could apply its own plan unread would
have removed the only step this feature exists for. An agent that finds its
task is really three jobs has task_split
instead. The annotated format is
examples/plan.toml.
The dispatch hook: the one push in a pull design
Everything above is pull, and one seam is left by hand: a task that becomes
workable while every agent is idle sits on the board, correct and silent,
until you notice and go find an agent to point at it. The
dispatch_hook config key closes that seam without a daemon.
Name a command and hird runs it — detached, through sh -c — at
the moment a task becomes claimable, with the announcement in its
environment:
# ${XDG_CONFIG_HOME:-~/.config}/hird/config.toml
dispatch_hook = 'herdr agent prompt worker "hird task $HIRD_TASK is ready; work the hird queue."'
| Variable | Meaning |
|---|---|
HIRD_EVENT | Why the task is claimable: filed, unblocked, review_filed, sent_back, reopened, released, answered, lease_expired or resumed — the last one meaning it waited behind a recess you have just lifted. |
HIRD_TASK | The task number. |
HIRD_TITLE | Its title. |
HIRD_PROJECT | Its project root. |
HIRD_RECUSED | Harnesses the queue will refuse this task to, comma-separated; empty for tasks anybody may take. A filed review names whoever did the work under review. |
HIRD_DB | The database, so the hook's own hird calls read the same board. |
hird neither waits for the command nor reads its answer — a broken hook cannot slow a claim or fail a completion — and the announcement is read back from the committed board, so a task another agent grabbed in the meantime is never announced. What the command does is your business: append to a log, post a notification, or — the case it was built for — summon an idle agent through a terminal multiplexer that can address one, such as herdr. Under herdr, the hook above turns the whole loop autonomous: a plan is filed and its first wave prompts agents to pull; a finish frees dependents and prompts for them; a completion files its review and prompts a different harness to read it; a sent-back verdict reopens the work and prompts for the redo — with you watching the TUI instead of carrying prompts between terminals.
One announcement in that loop is not for just anybody, and the hook is told
so. A hook that always prompts the same agent will, sooner or later, summon
it to a review of its own work — which the queue then
refuses, and the review sits silent again: the very seam the hook exists to
close, reopened for exactly the tasks where the second pair of eyes matters.
HIRD_RECUSED carries the fact only hird knows — which harness
the claim would turn away — so one line of shell routes the summons. Commas
cannot appear in a harness name, making the membership test safe:
dispatch_hook = '''
worker=claude; case ",$HIRD_RECUSED," in *,claude-code,*) worker=codex;; esac
herdr agent prompt "$worker" "hird task $HIRD_TASK is ready; work the hird queue."
'''
Because hird only runs when somebody calls it, an announcement happens when
the becoming-claimable is observed — which for every cause except
lease_expired is the same write that causes it. Expiry is
enforced lazily by sweeps, and every queue call —
every MCP tool, every CLI verb — sweeps before its real work and announces
what came back to the pool, so a dead agent's task is heard about by the
next call any other agent (or you) makes.
examples/dispatch-hook.sh
shows every announcement landing, no multiplexer required.
Questions have the same seam on the other side of the table, and the
question_hook key closes it the same way. A task
released with a question steps out of dispatch on
purpose — it is waiting for a human, not another agent, so the dispatch hook
stays deliberately quiet — but a human who is not watching the board waits
in the same silence an idle agent did. Name a command and hird runs it,
detached, the moment a question gate opens, with HIRD_TASK,
HIRD_TITLE, HIRD_PROJECT,
HIRD_QUESTION, HIRD_ASKED_BY and
HIRD_DB in its environment (HIRD_EVENT is always
asked, so one script can serve both hooks and branch on the
same vocabulary). Point it at whatever reaches you:
question_hook = 'notify-send "hird task $HIRD_TASK asks" "$HIRD_QUESTION"'
Answering stays a human act — hird answer or the TUI — and the
moment you do, the dispatch hook announces the task back to the agents as
answered. The two hooks together close the loop's last manual
mile in both directions: agents are woken when work appears, and you are
woken when work needs you.
Under herdr, the whole pairing also ships packaged as a
herdr plugin:
herdr plugin install aoprisan/hird/herdr-plugin wires the hook
for you, keeps the worker roster in a config file instead of a shell
one-liner, falls through to the next worker when an agent is gone, and puts
the board in an overlay pane.
The roster is an order you wrote once, and it cannot know that this task is
a sprawling refactor and that one a rename. hird will not choose for you —
it knows what a task requires and what a caller
advertises, but it has no roster and picks nobody. Fit is a
judgement, and judgements live in the hook: give the plugin a
jev page and
every announcement asks which harness suits the work, and that harness gets
first refusal. It is a preference and never a permission — the named
harness still has to be unrecused, equipped and free, the ordinary walk
follows immediately after, and anything that goes wrong along the way
leaves the order you wrote exactly as it was.
And it is asked only about the agents the task may actually go to. Recusal and required capabilities are applied to the page's labels before the call, not only to the answer afterwards, so a choice never spends its confidence on a harness the queue has already ruled out — and when they leave fewer than two harnesses in the running there is nothing to decide and no call is made at all. Which knob is which matters here: what an agent's environment has is a capability, enforced, and what an agent is better at belongs in the page's descriptions, where being wrong costs one summons.
The recess: standing the queue down
All of the above makes the queue live all the time: task_next
answers whoever asks, and with a hook wired, a task that becomes claimable
summons a worker within the second. The moment you need the tree to
yourself — a rebase, a merge landing, a plan you have stopped believing
in — there was no way to say so short of killing sessions or unwiring the
hook. Now there is one:
hird recess "rebasing main"
# queue for /home/you/app in recess ("rebasing main")
# nothing new is handed out; work already claimed continues
# hird resume lifts it
A recess stands one project's queue down. Named claims and
task_next alike are refused, carrying your reason, and the
dispatch hook stays quiet — a summons to a stood-down queue would send an
agent straight into a refusal. Work already claimed is untouched: leases
run, check-ins land, completions finish, because a recess stops the
hand-out, not the work. An agent asking for work is told the difference
out loud — in recess, not idle — so it stands by instead of
retrying, and hird ls, hird graph,
hird show --why and the TUI's status bar all wear the recess while it
stands.
hird resume
# recess lifted for /home/you/app — claims resume
Lifting it announces everything that became claimable while the queue stood
down — each task reaching the dispatch hook as
HIRD_EVENT=resumed — so the backlog summons hands the moment
you are ready for them, and not a moment before. Calling and lifting a
recess are human acts, like filing a plan, so
there is no MCP tool for either; a recess ends when you say so, never at a
time.
Registering a harness
Each harness runs its own hird mcp process over stdio. The only
thing they need to differ on is HIRD_HARNESS, which is how agents
and the board tell each other apart (claude-code:af31,
codex:9f2c). Leave it unset and the agent shows up as
unknown.
hird register writes that for you, into the one file the harness
in question actually reads:
hird register claude-code # ./.mcp.json
hird register codex # ~/.codex/config.toml
hird register copilot # ./.vscode/mcp.json — Copilot in VS Code
hird register copilot-cli # ~/.copilot/mcp-config.json
hird register gemini # ./.gemini/settings.json
hird register opencode # ~/.config/opencode/opencode.json
registered hird in /home/you/project/.vscode/mcp.json
command /home/you/.cargo/bin/hird mcp
env HIRD_HARNESS=copilot
next: in VS Code: MCP: List Servers → hird → Start Server, then tick hird in
the agent-mode tools picker
The command it writes is the absolute path of the binary doing
the writing, because that is the thing hand-written configs get wrong: a bare
hird resolves against the harness's PATH, and a GUI
editor's is not your shell's.
Register as many harnesses as you run — that is the point — and run it again
whenever you move the binary. Registering twice changes nothing and says so.
An entry you have since edited by hand is refused rather than overwritten,
with --force to say you meant it. --print writes
nothing and shows what it would have written, for a config this cannot safely
edit — one with comments in it, or a harness hird has never heard of.
A second registration on a scratch database, alongside the real one:
hird register codex --name hird-scratch --db /tmp/scratch/hird.db
--capability records what the registered session can satisfy —
a browser, network access, an operating system — as
HIRD_CAPABILITIES in the same entry, which is how
work that requires them finds the right hands.
What it writes
Claude Code
./.mcp.json
Project-scoped, so it belongs to the checkout rather than to you.
claude mcp add hird -e HIRD_HARNESS=claude-code -- hird mcp is
the user-wide equivalent.
Codex CLI
~/.codex/config.toml[mcp_servers.hird]
command = "/home/you/.cargo/bin/hird"
args = ["mcp"]
env = { HIRD_HARNESS = "codex" }
Appended, with the comments already in the file left alone.
Copilot in VS Code
.vscode/mcp.json{
"servers": {
"hird": {
"type": "stdio",
"command": "/home/you/.cargo/bin/hird",
"args": ["mcp"],
"env": { "HIRD_HARNESS": "copilot" }
}
}
}
Writing this does not start it: MCP: List Servers → hird →
Start Server, and tick hird in the tools picker
of the agent-mode chat box. VS Code does not launch a newly written server on
its own, and a stopped or unticked server looks exactly like one that was
never registered.
Copilot CLI
~/.copilot/mcp-config.json{
"mcpServers": {
"hird": {
"type": "local",
"command": "/home/you/.cargo/bin/hird",
"args": ["mcp"],
"env": { "HIRD_HARNESS": "copilot" },
"tools": ["*"]
}
}
}
/mcp add inside a session writes the same file.
Gemini CLI
.gemini/settings.json{
"mcpServers": {
"hird": {
"command": "/home/you/.cargo/bin/hird",
"args": ["mcp"],
"env": { "HIRD_HARNESS": "gemini" }
}
}
}
Project-scoped, the same scope gemini mcp add defaults to, and
there is no type to get wrong: the Gemini CLI reads a
command as stdio.
gemini mcp add -e HIRD_HARNESS=gemini hird hird mcp writes the
same entry — with the bare hird this command exists to avoid —
and its --scope user writes
~/.gemini/settings.json instead, which every project reads and
the project file merges over. The CLI tolerates comments in
settings.json and no JSON parser does, so a commented file is
left untouched and --print renders the block to merge by hand. Restart the Gemini CLI after registering, and
gemini mcp list confirms the connection.
OpenCode
${XDG_CONFIG_HOME:-~/.config}/opencode/opencode.json{
"mcp": {
"hird": {
"type": "local",
"command": ["/home/you/.cargo/bin/hird", "mcp"],
"environment": { "HIRD_HARNESS": "opencode" }
}
}
}
If opencode.jsonc already exists and
opencode.json does not, hird uses the JSONC path. A file
containing comments is left untouched; --print renders the
block to merge by hand. Restart OpenCode after registering, and
opencode mcp list confirms the connection.
Any other MCP-capable harness works the same way: run hird mcp
over stdio and set HIRD_HARNESS to something recognisable.
hird register copilot --print is a reasonable starting point for
one hird has no entry for. Whichever way it is registered, the server states
the queue's rules when the harness first asks — claim before working, heartbeat
before the lease runs out, declare your files, write down what you learn — so
agents generally behave without being coached in your prompt.
The protocol
hird mcp speaks MCP 2026-07-28, and every
earlier revision back to 2024-11-05. Which one a session uses is the harness’s
choice, not something to configure — hird answers whichever it is asked
for, and says so by name when asked for one it does not have.
2026-07-28 removes the initialize handshake: a client opens with
server/discover, or simply with the request it wanted to make,
and carries the protocol version, its own name and its capabilities in
_meta on every one. Nothing hird does depended on that handshake
— it is one process per session over a pipe, which is about as
stateless as a session gets — so a harness on the new lifecycle and one
still handshaking share a queue without noticing each other’s era. The
one visible difference is that a request claiming 2026-07-28 without carrying
what 2026-07-28 requires is refused as a bad request, in a sentence naming
what was missing, rather than served on a guess about who sent it.
The revision also means a client now names itself on every call, which hird
uses for exactly one thing: a harness that never set
HIRD_HARNESS is filed under the name its client gives, instead of
unknown. HIRD_HARNESS still wins wherever
it is set — it is the half of the identity you control, and
hird register writes it — and the name is taken once and
then held for the life of the session, because an actor that changed its name
halfway through would lose track of its own claims.
What hird has never used, and does not start using now, are the three features this revision deprecates: no roots, no sampling, no protocol logging.
When the agent says it has no hird tools
A skill, a prompt file or a copilot-instructions.md that talks
about task_claim is not a connection. It tells an agent what to
do with the tools; it cannot hand it any. Registering the server is a
separate step, and it is the one above. If the agent reports no
task_* tools, work down this list.
Is the server registered where that harness reads? Each
registration above belongs to exactly one harness. Copilot in VS Code does
not read ~/.copilot/mcp-config.json, and the Copilot CLI does
not read .vscode/mcp.json. hird register <harness>
picks the file for you; run it again and it will say
already registered against the path it went to.
Can the harness spawn hird?
command: "hird" resolves against the harness's PATH,
not your shell's. A GUI VS Code launched from Finder or a dock has neither
~/.cargo/bin nor anything else your shell profile adds, so the
spawn fails and the server dies before the handshake. This is what
hird register exists to get right; in a config written by hand,
paste the absolute path instead:
which hird # → /Users/you/.cargo/bin/hird
What did the server say? Every harness keeps the stdio
server's output. In VS Code it is MCP: List Servers →
hird → Show Output; in Claude Code,
claude mcp list. A hird that starts prints nothing
and waits, so an empty log is the healthy case and
command not found is the usual one.
Is it the cloud coding agent? The Copilot coding agent on
github.com runs in an ephemeral container with no access to your machine.
hird is a local queue in a local SQLite file, so there is nothing there for
it to connect to — register hird in an editor or CLI that runs on the same
machine as the database (hird db-path). What such a session
can be given is the brief: hird show <seq> --brief
renders everything a claim would hand an agent as Markdown, to paste where
the queue does not reach.
Confirm the binary works at all before blaming the wiring: hird ls
from a terminal exercises the same database over the same code the server
does.
How a task moves
open ──claim──► claimed ──start──► in_progress ──complete──► done
▲ │ │ └─fail────► failed
│ └── lease expiry ────┘
│ └──── release ───────┘
└──────── reopen (human) ◄── done|failed|cancelled
open ──cancel (human)──► cancelled
Claiming is atomic. It is a single compare-and-set, so if two agents reach for the same task at the same moment exactly one wins.
Claims are leases, not locks. A claim lasts 15 minutes by
default and every task_update renews it. If an agent crashes,
gets killed, or simply wanders off, the lease lapses and the task returns to
open on its own — no stuck tasks, no manual cleanup, no
background process. Expiry is enforced lazily by whoever reads the table next,
which is usually the TUI half a second later.
Only the lease holder can drive a task. Another agent cannot
complete, fail, or update work it does not hold. You, however, can always
cancel or reopen anything from the CLI or the TUI.
An agent that cannot finish hands the task back.
task_release returns it to open immediately, which is
not the same as failed — a failure is a verdict on the task and
waits for you to reopen it, a release just says this agent stopped.
An agent that needs your answer stops the churn. A
task_release carrying question returns the task to
open but keeps it out of claims and task_next until
you run hird answer <seq> <answer> or press
A on its card. The answer makes it claimable, announces
HIRD_EVENT=answered, and rides in the next claim beside the
question. Releasing without a question remains an immediate handoff.
No agent is summoned to it: a question is the one stall no agent can clear,
so the dispatch hook stays silent rather than waking somebody who would
only hand it around again. On the board, hird ls marks the
row, hird show prints the command that answers it, and the TUI
counts them in its status bar (· 2 awaiting you) — and if
noticing should not have to wait for a glance, the
question_hook config key runs a
command of yours the moment the question is raised, with the question in
its environment.
Dependencies
Dependencies are enforced, not annotated. A task whose dependencies are
unfinished cannot be claimed at all — by number or by
task_next — and the refusal says what to do about it:
task 2 is blocked by task 1 (Design the storage schema, in_progress);
it becomes claimable once every dependency is done
Only done clears a blocker. A failed dependency keeps its
dependents off the ready list, because the work they were waiting for did not
happen.
# at filing time
hird add "Write the release notes" --needs 1,2
# or afterwards
hird dep add 3 --needs 1,2
hird dep rm 3 --needs 2
# see the whole plan as dispatch waves
hird graph
hird graph prints waves rather than edges, because that is the
shape you actually want: how much of this can run at once, and what the
critical path is.
Work knows what its worker must have
Harnesses are not interchangeable. One may have a browser, another network access or deployment credentials, and a third may be the only session running on macOS. Put those hard constraints on the work instead of discovering them after the wrong agent takes a lease:
hird add "Verify the responsive UI" --path 'frontend/**' --requires browser
hird register codex --capability network
hird register claude-code --capability browser --capability network
The registration writes HIRD_CAPABILITIES; labels are lowercase
free-form tokens such as browser, network,
macos or deploy.staging. A named claim from an
unequipped session is refused before it writes anything:
task 7 requires browser, but this session advertises network; start a capable
harness or update HIRD_CAPABILITIES
task_next routes around the mismatch and reports skipped tasks
under incompatible, alongside blocked, contended and recused
work. This does not make hird an agent router: the queue knows what the task
requires and what this caller advertises, but it has no roster and chooses
nobody. The dispatch hook receives the same
requirements as HIRD_REQUIRES and remains the place that maps
labels to workers. Reviews inherit the work's requirements, because checking
browser-only work generally requires a browser too; use
hird require <seq> --clear when a particular review only
needs its diff.
File scope and collisions
The queue also knows which files each task expects to touch, so it can see the one failure mode a status machine cannot: two agents editing the same file from two harnesses, one of them about to lose their work.
Declarations are globs, not files, because nothing has been written yet — and
overlap is decided by asking whether any path at all is described by
both patterns. src/*.rs and src/lib* collide before
src/lib.rs exists.
$ hird agents
codex:9f2c #4 in_progress Port the config loader 11m left
files src/config.rs, tests/config.rs
claude-code:af31 #7 claimed Rewrite the loader tests 14m left
files tests/**
!! tests/** also claimed by codex:9f2c on #4
The agents see it too, the moment they say what they are about to change:
{ "seq": 7, "paths": ["tests/**"],
"overlaps": ["tests/** overlaps tests/config.rs on task 4 (Port the config loader), held by codex:9f2c"],
"advice": "tell the human about the overlap before editing those files, …" }
By default task_next simply does not hand out work that would
collide — with several tasks to choose from there is no reason to pick the one
that clashes. A claim by number is still allowed to overlap, and only
reports it, on the theory that you named that task on purpose. Set
path_conflicts = "refuse" to make an overlapping claim fail
outright instead.
Scope can be set at filing time with --path, adjusted later with
hird scope, or declared by the agent itself through
task_scope once it knows where it is going.
What actually happened
Everything above is what an agent said. A task's status is its holder's claim about itself, its file scope is its holder's prediction, and its result is a sentence the holder wrote about its own work. That is fine right up until three agents share one checkout, where the failure that costs you work is the one nobody reports.
Play the collision out to the end. Two agents declare
src/config.rs. Both are told about the overlap. Both decide their
part is small. Both edit. The second write lands on the first, both tasks
complete, the board goes green — and git has nothing to show you, because
nothing was committed and there is only ever one version of the file on disk.
Not one of the three participants is in a position to notice.
So hird goes and looks. A claim fingerprints the repository,
every check-in takes another fingerprint and subtracts, and the results say
what moved:
{ "seq": 1, "status": "in_progress", "note_recorded": "loader ported",
"changed": ["src/config.rs (modified)", "src/mcp.rs (added)"] }
That list did not come from the agent. It came off the disk.
The overlap that has stopped being hypothetical
What the working tree cannot tell you is who — one filesystem, no
keyboards — and hird does not pretend it can. What it has instead
is the other half of the picture, which it was already collecting: a declared
scope is an agent saying it holds a copy of a file and means to write from it.
A contention is a file two live agents both declared, which has since moved under both of them, and which the two of them disagree about.
All three clauses earn their place. Both declaring is the attribution the filesystem cannot supply. Having moved is what makes it an event rather than a forecast — the plain overlap is already on the board. Disagreeing is what makes it worth interrupting an agent over: two agents who have both been shown the current file are not in trouble yet, and warning them anyway is how a warning stops being read.
When all three hold, both sides hear about it on their next call, by name:
{ "contended": ["src/config.rs changed under task 2 (Audit the config loader), held by
claude-code:af31 at 14:36 UTC, at or after the version hird last confirmed
for you — re-read it before you write, or your edit will discard theirs"],
"advice": "a file you declared has changed under another agent that declared
it too — re-read it before your next write, and tell the human" }
Re-reading the file is the whole fix, and it is the one thing neither agent would have thought to do.
Drift
An agent that wanders outside what it declared is told so, because everyone else's collision check is reading the declaration and not the edits:
{ "undeclared": ["src/mcp.rs"],
"advice": "src/mcp.rs moved while you held this task and nobody has declared
it. If the edit was yours, call task_scope so the other agents can
see it; if it was not, another agent is working outside what it
declared" }
The conditional is deliberate. Alone in a project, a file that moved moved
because of you. With another agent live, hird watched the file
and not the keyboard, and it says so rather than guessing.
Did it change anything, or did it only read?
The same fingerprints answer a blunter question, and it is the one you ask first when a task comes back done: did this work touch the code at all?
$ hird ls
#1 done Port the config loader modified 2 files
#2 done Audit the config loader read-only
#3 in_progress Write the release notes [codex:9f2c] 11m left read-only so far
An investigation that changed nothing and a refactor that rewrote half the module are the same green card otherwise, and the difference decides whether there is anything to review, anything to test, or anything to undo.
Three answers, and the third one matters as much as the other two:
-
modified N files— the tree differs from the fingerprint taken when the task was claimed. Where another agent was live in one of those files, the line says so, because one checkout has no keyboards. -
read-only—hirdwas watching, and nothing moved. On a task still being worked it readsread-only so far: a running total, not a verdict. -
nothing at all —
hirdwas not watching, so it has no opinion. Saying read-only here would be inventing evidence, and the whole point of the witness is that it does not.
Agents are told the same thing in the same words, on every check-in and on the call that finishes the work:
{ "seq": 2, "status": "done", "result": "read it; the precedence is already right",
"footprint": "read-only — nothing in the working tree moved while it was held" }
On the board
$ hird agents
codex:9f2c #1 in_progress Port the config loader 11m left
files src/config.rs
moved src/config.rs, src/mcp.rs
!! src/config.rs also claimed by claude-code:af31 on #2
!!! src/config.rs changed under task 2 (Audit the config loader), held by
claude-code:af31 at 14:36 UTC … re-read it before you write
files is what was announced; moved is what happened
— or moved nothing yet where an agent has been in the
code and written none of it — and the gap between the two lines is the point.
hird show carries the same record, headed by the one-line answer:
$ hird show 1
…
changed modified 2 files, though another agent was live in some of them
src/config.rs (modified)
src/mcp.rs (added)
That is the evidence behind a finished task's result line, and it was not written by the agent that wrote it. The TUI's Swarm screen shows both, and titles the pane with how many agents are standing in a file that is moving under them; the queue board badges every card with what it did to the tree.
The witness keeps what it saw
Detection is half an answer. The witness could tell you
src/config.rs moved under task 1; it could not show you the
change, and when a second agent's write landed on the first's uncommitted
work it could name the loss but not undo it. So every file version the
witness fingerprints is also kept — content-addressed,
deduplicated, in the same SQLite file as everything else — and three things
read it back.
$ hird diff 1
diff --git a/src/config.rs b/src/config.rs
--- a/src/config.rs
+++ b/src/config.rs
@@ -1 +1 @@
-fn load() {}
+fn load() { ported() }
That is the uncommitted diff of what actually moved under task 1 — while the
work is live, after it is done, and after the tree has moved on to something
else. Git remembers what was committed; the exhibit remembers what happened
between commits, which is where agents live. --path narrows it
to one file.
The review a completion files is the second reader:
its brief now carries the diff of the work under judgement, clipped to a
size a context window can afford and pointing at hird diff for
the rest — so the reviewing agent reads the change itself rather than
reconstructing it from a list of file names.
$ hird salvage 1 src/config.rs --out recovered.rs
salvaged src/config.rs as the witness last saw it under the task — 24 bytes into recovered.rs
And salvage is the third: the last version the witness saw of a
file under a task, which is exactly the version another agent's write landed
on in the collision the witness was built to catch. --baseline
asks instead for the version the task started from. The name is the honest
limit — a version that came and went between two observations was never
seen, and hird will say a version is not kept rather than guess at one.
Kept versions are pruned once nothing references them and they have aged
out, so the store grows with what actually changed, not with time.
exhibit = false keeps the watching and gives up the keeping.
A task remembers every hand that held it
An agent claims a task, edits two files, and dies — session killed, laptop closed, context burned out. The lease lapses and the task goes back in the pool. Whoever picks it up next starts on a working tree carrying a vanished agent's uncommitted edits, looking exactly like code that was always there.
So the record survives the task changing hands. When a lease expires
mid-work, work is released, or a review sends it back, the next claim
archives the previous holding as a tenure — who held it, how it
ended, what moved — instead of overwriting it, and the successor's claim
answer carries previously: whose uncommitted leavings are in
the tree it starts from, and which files to re-read before building on or
over them.
{ "claimed": 7,
"previously": "codex:9f2c went quiet until the lease expired, and these files moved
while they held it: src/config.rs (modified), src/loader.rs (added). Whatever state
they left is part of the tree this claim starts from — `hird diff 7 --tenure 1`
shows that round's changes before you build on or over them" }
hird show numbers the rounds (held round 1: codex:9f2c —
lease expired; saw src/config.rs (modified)), and hird diff
--tenure N and hird salvage --tenure N keep every earlier
round readable and recoverable after the redo has written over all of it. A
review that sends work back gets the same benefit for free: the redo no
longer erases the evidence of the round the reviewer judged.
It is never in the way
Watching needs git, and a project without it is not a degraded project — every
tool answers exactly as it did before, and agents are not told about a field
nothing will ever fill. A sweep that fails costs a report nobody asked for,
never the call that was actually made. .gitignore decides what
counts as noise, so build output is free. And with no task in the project
holding a lease there is nobody to measure, so git is not run at all.
Measured on hird's own repository: about 8 ms for a claim and 6 ms
for a check-in, against 0.5 ms for a call that never looks — three git
subprocesses, against a heartbeat measured in minutes.
witness = false turns it off.
Splitting a task
An agent that finds its task is really three jobs does not have to do them in sequence, or hand the problem back to you:
task_split { seq: 1, subtasks: [
{ title: "Write the migration", paths: ["src/db.rs"] },
{ title: "Port the repositories", paths: ["src/repo/**"] },
] }
The pieces are filed as real tasks. Task 1 starts waiting for both and goes
back in the pool — blocked, so nobody can claim it early — and the agent that
split it is free to pick up something else. The other harnesses take a piece
each, in parallel, and task 1 becomes workable again the moment they are both
done. Pass sequential: true when the pieces genuinely have to
happen in order.
No agent reviews its own work
Every result line in the queue was written by the agent that did the work. It is the last word, and nobody else ever looks. With one agent that is not a choice. With three it is a waste of the only thing that makes running three worth doing.
Every harness can review code already. None of them can tell whose code it is looking at — a harness cannot see another harness's session, and an agent handed "review this" has no way to know it wrote the thing an hour ago. hird can: it is the process all of them talk to, and it wrote down who held the lease.
hird add "Port the config loader" --review --path src/config.rs
That is the whole opt-in, and it says nothing about who or when. Codex claims it, works it, and finishes the way it always does:
task_complete { seq: 1, result: "ported; env still wins over the file" }
{ "seq": 1, "status": "done", "changed": ["src/config.rs (modified)"],
"review_filed": 2,
"advice": "this work was marked for review, so task 2 is now open for an agent
in another harness — you cannot take it yourself. Tell the human." }
Task 2 was filed by the completion. It is titled after the work, scoped to the file the witness saw move rather than the one anybody declared, and its body carries codex's own summary marked as the thing under review rather than as the brief. Then:
task_next {} # asked by codex
{ "idle": "1 task is ready, but every one of them is a review of work this
harness did — they need an agent in another harness. Tell the human;
waiting will not change it",
"recused": [{ "seq": 2, "why": "not whoever worked task 1 (Port the config
loader): that was codex:43em, so this needs another harness" }] }
Not "nothing to do". The queue is not idle — it is waiting for a different tool, and an agent told the queue was empty would send you away from the one thing that needs you. Claiming it by name is refused too, in the same transaction as the compare-and-set, so there is no race to win.
The bar is the harness, not the session. Two Claude Code windows are one model reading its own homework; a bar you could get around by opening a tab would be no bar at all.
It is a constraint, not a scheduler. A recusal says who may not take a task and never who must. Run one harness and the review simply sits there unclaimable — which the board says plainly, because that is a fact about your setup rather than something to paper over.
You can set the bar by hand, and lift it:
hird recuse 7 --from 4 --reason "wrote the thing being checked"
hird recuse 7 --clear
And a plan file is where the judgement belongs, since which work deserves a second pair of eyes is a property of the job:
[[task]]
name = "renderer"
title = "Rewrite the renderer"
paths = ["src/tui/**"]
review = true
Nothing is filed for work the witness saw no trace of, for work that
failed — there is nothing to check, and whether the attempt is
worth reading is your call — or while an earlier review of the same work is
still open.
The review closes its own loop
Getting the work in front of a second harness is half the job. The other half used to be yours: read the review, decide "the error path drops the lock" means broken, find the task it reviewed, reopen it, paste the findings somewhere. Every other hand-off in hird files itself; the one carrying the judgment was left by hand.
So a review ends in a verdict, and completing one without it is refused — the refusal says what the two verdicts are and what each will do:
task_complete { "seq": 2, "result": "the file path still wins when both are
set; invert the precedence in load()",
"verdict": "sent_back" }
{ "seq": 2, "status": "done",
"verdicts": ["task 1 sent back — open again with your findings appended to
its brief"],
"advice": "the work you sent back is open again carrying your findings; any
agent — its author included — may pick it up, and completing it
will file a fresh review. Tell the human the verdict." }
sent_back acts in the same transaction as the completion: the
judged work reopens with the findings appended to its brief, so the next
agent to claim it — its author included — is handed exactly what must change
without knowing to ask. The work still carries its review flag, so finishing
it again files a fresh review, recused the same way, and the loop runs round
after round until a review says upheld. Upholding is a
signature: the card now reads done, and seen to be done by a harness
that provably did not do the work.
You keep the last word you always had. A sent_back that lands on
work you already reopened or cancelled goes on the record and moves nothing —
the queue never overrules a human. You just stop being the loop's courier.
And because every verdict is delivered on the record — who judged, whose work, which round — the queue accumulates the one measurement it is uniquely placed to take:
$ hird record
as worker judged upheld sent back first pass
claude-code 3 3 0 2/2
codex 2 1 1 0/1
as reviewer upheld sent back
claude-code 1 1
codex 3 0
That is whose work survives a reading by a different model, per harness, off delivered verdicts and nothing else. It is a report, not a scheduler: nothing routes work by it — the moment the queue started steering tasks toward whoever scores well, agents would be graded by a table they can see, and the table would stop measuring anything. What to do about a harness that ships rework stays your call, made over a table instead of a hunch.
The ground a task builds on
A dependency edge means one task needs what another produces — "the API waits for the schema" is about knowing the schema, not just outliving it. So the edge is a context channel, not only a gate: claiming a task hands over each finished dependency's own result, written by whoever finished it, without the claimant knowing to ask.
task_claim { "seq": 2 }
{ "claimed": 2, ...
"built_on": [{ "seq": 1, "title": "Design the storage schema",
"result": "schema in src/db.rs; migrations are an array,
version = index + 1",
"standing": "done" }] }
standing says how far that word can be trusted, the same move
memory's footing makes for facts: done, upheld —
read and signed for by a harness that provably did not write it — or
under review 5, provisional, which means the review has not
delivered its verdict and a sent_back could still reopen that
work.
Provisional matters because verdicts made done revocable. A
dependent claimed while its blocker's review is still open is building on an
answer that can be taken back — and when that happens, the queue is the only
participant positioned to notice. Now it does something about it, twice
over: the reopened work's live dependents get a ground_shifted
event on their trails in the same transaction as the verdict, and each
holder hears at its next check-in, ahead of everything else the reply could
say:
task_update { "seq": 2, "note": "wiring the loader in" }
{ ...,
"ground_shifted": ["task 1 (Design the storage schema), which this task
builds on, was sent back by review 5 and reopened; re-read it — the
findings are in its brief — before building further on its work"] }
The same sentence catches ground lost any other way — a blocker you reopened or cancelled, or one that failed since — attributed no further than its status can back. Nothing interrupts an agent mid-edit: hird tells, at the next moment it is listening, and lets it decide — the same manners the witness has.
Whether provisional ground should hold dependents back is a policy,
not a rule: under_review = "clears" (the default) makes them
workable at once and tells them what they stand on; "holds"
keeps them unclaimable until the verdict, for work whose later stages cost
more than the wait. Under holds the refusal and the board both
say what the wait actually is — waits for #1 (done, under review
5) — and task_next reports held tasks in their own
bucket, because "finished but for a verdict" points you at a review to
chase, not at work to do. On every board, a done card with an
open review wears under review #5 until the verdict lands: the
difference between done and done, so far as anyone has
checked.
Memory
The queue is for work; memory is for what the work taught you. Agents call
mem_store with one plain-prose fact — where something lives, why a
decision went the way it did, which command actually works — and
mem_search finds it later, from any harness, in any session.
Every assertion carries provenance: who asserted it, when, and which task they
were working at the time. Facts are never edited or deleted; a fact that stops
being true is superseded by a new one, and the old version stays
searchable behind --include-superseded.
hird mem add "Integration tests need HIRD_DB set or they touch the real db" --tags testing
hird mem search testing
Search is SQLite FTS5. If a query is not valid FTS syntax — an agent typing
handle_event(ctx) — it degrades to a term-wise substring match
instead of erroring.
Recall: the task arrives knowing things
mem_search only helps an agent that thinks to search, and an agent
that has just been handed a task does not yet know what it does not know. The
queue does. It knows which files the task expects to touch, and it knows what
earlier agents recorded while working those same files — so a claim comes back
with those facts already attached.
task_claim { seq: 7, paths: ["src/*.rs"] }
{ "claimed": 7, "title": "Audit the loader",
"recalled": [
{ "content": "the loader reads HIRD_DB before the config file",
"why": "learned on task 4 (Port the config loader), working src/config.rs",
"actor": "codex:9f2c", "task_seq": 4 } ],
"reminder": "read `recalled` first — earlier agents left those notes …" }
Nobody searched, and the two tasks never mention each other: overlap is decided
by the same exact pattern intersection the collision detector uses, so an
assertion recorded on src/config.rs reaches a task that declared
src/*.rs. Declaring your paths early is what makes this work,
which is the second reason to do it.
Territory is not only what was declared, though. The
witness knows which files actually moved under a task,
and those count on both sides — which matters because declaring a scope is
exactly the step an agent in a hurry skips. An agent that said nothing and
went ahead and edited src/config.rs still leaves the file behind
it, and what it learned still reaches whoever comes next.
Three things make a fact relevant, strongest first:
| Because | The why line reads |
|---|---|
| it was recorded on this task, on an earlier pass | recorded while working this task earlier |
| it was recorded on a task whose files overlap yours | learned on task 4 (…), working src/config.rs |
| it reads like your title | mentions "config", "loader" |
Every recalled fact says where it came from, because an assertion is a claim
and not gospel: an agent that finds one is wrong should
mem_store the truth, which supersedes it and stops it being
recalled again. Nothing is stored for recall — it is derived at read time from
the assertion trail and the declared scopes, so there is no index to rebuild
and no migration to run.
task_get carries the same list without claiming anything, and
hird show <seq> --recall shows a human exactly what their agents are
being told. Five facts ride along by default; set
recall_limit = 0 to switch it off entirely.
Footing: a fact knows what it was learned against
A memory that only grows eventually lies to you. Not by filling up with falsehoods — you would notice those — but by filling up with sentences that were true. A fact recorded in March about a file that has been rewritten twice since arrives in July in exactly the same voice as one recorded this morning, and nothing about reading it tells you which is which.
The sentence cannot tell you. The code can. So hird records what an assertion was read off — the files behind it, and the content hash each one had at the time — and every later reader is told whether that ground has moved.
$ hird mem standing
firm the loader reads HIRD_DB before the config file
01J… codex:9f2c 2h ago
src/config.rs
shaky the renderer redraws on every poll
01J… claude-code:af31 3d ago
src/tui/view.rs
src/tui/view.rs has changed since this was recorded — re-read before relying on it
2 anchored: 1 firm, 1 shaky
Nobody curated that, and nobody told hird that src/tui/view.rs
had changed. The fact remembers which file it came from and what that file
said, and the file no longer says it.
The same word rides along everywhere a fact is served — mem_search
results, the memory browser, and the recalled list a claim arrives
with, which is the one that matters most because it lands in an agent's
context unasked:
"recalled": [
{ "content": "the renderer redraws on every poll",
"why": "learned on task 4 (Fix the renderer), working src/tui/view.rs",
"standing": "shaky",
"caution": "src/tui/view.rs has changed since this was recorded — re-read before relying on it" } ]
It never says a fact is false. A rename, a formatting pass and
a total rewrite look identical from here. shaky means
unverified, which is a weaker claim and a far more useful one: it is
exactly the set of facts where opening the file pays for itself.
orphaned — every file it was about is gone — is the strongest
thing hird will say, and it still stops short of a verdict.
Where the files come from needs nothing from the agent. A fact stored with
task_seq is anchored to the literal paths that task declared plus
everything the witness saw it move;
mem_store { paths: […] } names them outright for a fact that
belongs to no task. A fact with neither stays unanchored, and hird says
nothing about it rather than guessing.
Saying it again is how you say you checked
An agent that re-reads a shaky fact and finds it still true has one way to say so, and should not need to know any more than that: say the fact again.
$ hird mem add "the renderer redraws on every poll" --path src/tui/view.rs
01J…
already on record — affirmed, not duplicated and re-anchored
No second row, no lost provenance. The original is re-anchored to today's code and you are recorded as another voice for it. That kills duplicate assertions — the oldest complaint about memory stores — and buys something else on the way past: hird counts voices, not sentences, so it can say the thing no single harness is positioned to say.
also stated by codex:9f2c, independently across 2 harnesses
Two agents that cannot see each other's sessions arrived at the same fact. Only the process both of them talk to can know that.
Why it stays quiet
A task that records a fact and then keeps editing the same file would, left
alone, mark its own fact shaky by its own hand. Finishing a task
settles what it learned against the tree it is leaving behind,
so shaky keeps meaning somebody else moved this — which
is the only reading worth a warning, and a warning that fires on everything is
a warning nobody reads.
It rides on the same working-tree access as the witness and is off wherever
that is: no git, or memory_footing = false, and memory behaves
exactly as it did before any of this existed — no anchors, no
standing field, and the handshake does not mention it.
Command line
hird add <title> [--body <md>|--body-file <path>] [--priority N] [--project <path>]
[--needs <seq>,…] [--path <glob>]… [--requires <capability>]… [--review]
hird ls [--status <status> | --at <when>] [--all-projects]
hird show <seq> [--why | --recall [--limit N] | --brief]
hird show <path>
hird diff <seq> [--path <file>] [--tenure N]
hird salvage <seq> <path> [--baseline] [--tenure N] [--out <file> [--force]]
hird cancel <seq> [--reason <text>]
hird reopen <seq> [--reason <text>]
hird answer <seq> <answer>
hird recess [reason] [--project <path>]
hird resume [--project <path>]
hird dep add <seq> --needs <seq>,…
hird dep rm <seq> --needs <seq>,…
hird plan apply <file> [--dry-run] [--project <path>]
hird plan lint <file> [--project <path>]
hird plan export [--plan <name>] [--name <name>] [--all] [--project <path>]
hird graph [--all-projects] [--plan <name>] [--dot | --mermaid | --json]
hird scope <seq> [--path <glob>]… [--clear]
hird require <seq> [--capability <name>]… [--clear]
hird agents [--record [--by harness|person]] [--all-projects]
hird recuse <seq> --from <seq>,… [--reason <text>] | --clear
hird events [--follow] [--json] [--kind <kind>,…] [--task <seq>] [--actor <name>]
[--limit N] [--all-projects]
hird events --digest [--since <when>] [--peek] [--all-projects]
hird mem add <content> [--tags a,b] [--task <seq>] [--path <file>]…
hird mem search [query] [--limit N] [--all-projects] [--include-superseded]
hird mem standing [--shaky] [--all-projects]
hird mem export [--path <glob>] [--firm]
hird tui
hird web [--port N] [--bind <addr>] [--all-projects]
hird mcp
hird register <claude-code|codex|copilot|copilot-cli|gemini|opencode> [--name <name>]
[--capability <name>]… [--print] [--force]
hird db-path
| Command | What it is for |
|---|---|
add | File a task. Prints the number, which is how you and the agents refer to it. |
ls | The queue as rows: status, holder, lease countdown, what it waits for, and whether it changed anything. --at 2h (or an instant, prefixes fine) folds the trail back into the board as it stood at that moment instead. |
show | One task in full — body, dependencies, scope, history, what was learned on it, and what the working tree says it did. Three flags ask it one narrower question: --why, whether it is claimable right now and, if not, every gate in the way in the order dispatch checks them; --recall, what earlier work learned that an agent is handed when it claims it; --brief, the claim brief as Markdown for a session that cannot reach the queue. Given a path instead of a number, show answers for a file: which tasks declared it, whose hands the witness saw in it, and what the memory says about it. |
diff | The uncommitted diff of what moved under a task, from the versions the witness kept — even after the task is done and the tree has moved on. --tenure N reads an archived round instead, after the task has changed hands. |
salvage | Recover a version of a file as it stood under a task: the last one the witness saw, or with --baseline the one the task started from. --tenure N reaches an earlier holding's record. |
cancel / reopen | Human overrides. They work regardless of who holds the task. |
answer | Resolve the question holding an open task out of dispatch; the answer is handed to its next claimant. |
recess / resume | Stand this project's queue down and lift it again — the recess. While it stands nothing is handed out and the dispatch hook is quiet; work already claimed continues. Resuming announces everything that waited. |
dep | Add or remove "must wait for" edges after the fact. |
plan | File a whole graph from a plan file, in one transaction. --dry-run reads it out — waves, overlaps and all — and writes nothing. lint reads the same file for the trouble it can carry while still filing cleanly: unordered tasks on the same files, tasks the collision radar cannot see, reviews that will never be filed or that nobody present could claim. export is the round trip: the board written back out as the TOML apply reads, unfinished tasks by default and everything with --all, tasks keeping the names their plan gave them. |
graph | The plan as dispatch waves: workable now, and what each later wave waits for. --dot and --mermaid draw it; --json is the board as one object, for anything that draws it; --plan narrows any of them to one plan's tasks. |
scope | Show or set the files a task is expected to touch. Warns about overlaps. |
require | Show or set the capabilities a task's worker must have. Supplying any replaces the set; --clear empties it, which a review only needing its diff sometimes wants. |
agents | Who is working what, what has moved under each of them, and a !! line wherever two agents are in the same files. --record reads each harness's track record under review instead: work upheld, work sent back, first-pass rate, and the verdicts it has handed out. |
events | The append-only trail read across tasks, oldest first — the board as a log. --follow keeps reading; --json makes each line one object. --digest folds what happened since you last looked into news, moving a bookmark so the next one starts where this ended. |
mem | Record and search assertions. export writes the project's current facts as Markdown for a CLAUDE.md or AGENTS.md — footing-aware, so a fact whose ground has been rewritten goes out marked for a re-read. |
tui | The live board. |
web | The live board as a picture in a browser — the graph, running. Loopback, read-only, gone when the terminal is. |
mcp | Serve MCP on stdio. Harnesses run this, you generally do not. |
register | Write the MCP registration into the one file a harness reads, with the binary's absolute path. --capability records what that session can satisfy; --print shows the entry without writing it. |
db-path | Print the database path this invocation would use. |
--body-file - reads the task body from stdin, and
hird plan apply - reads a whole plan from it, so either can be
piped straight into the queue. ls, show,
agents and events sweep the working tree as they
render, so all four report what has actually moved alongside what was
declared — and say plainly when the answer is nothing. Every CLI action is recorded with
actor = "cli", so the history pane shows who did what.
The TUI
hird tui
Four screens, Tab between them (Shift-Tab goes back). The board polls every 500 ms, so claims from other harnesses appear as they happen.
The Swarm screen is the one to watch while several agents are running: every live agent, the files it has declared, an overlap line in red wherever two of them are in the same territory, and — on the right — what is workable right now and how much is queued behind it.
Anywhere
| Tab | next screen (Shift-Tab for the previous) |
| p | toggle project filter (current / all) |
| / | filter or search |
| ? | help |
| q | quit |
Queue board
| j k | move down / up |
| h l | previous / next column |
| g G | first / last card |
| Enter | open the task and its history |
| a | add a task (Tab for the body, Enter to save) |
| A | answer the selected task's question |
| c r | cancel / reopen the selected task |
Memory browser
| j k | move down / up |
| Enter | show the assertion and its provenance |
| d | supersede it with something truer |
| s | show or hide superseded assertions |
| f | only the facts whose files have moved since |
Swarm
| j k | move between agents |
| Enter | open the task that agent is holding |
Graph
| j k | move within a wave |
| h l | previous / next wave |
| Enter | open the selected task |
The Graph screen is the plan laid out the way the queue will
run it: one column per dispatch wave, with the finished work live tasks still
build on in a column of its own on the left. Each card says what it needs and
what it feeds, and selecting one lights up its neighbours. Edges are said
rather than drawn — a terminal is a poor medium for a dozen crossing arrows,
which is what hird web is for.
Cards on the queue board carry a yellow waits #1 #3 badge when a
task looks open but nobody can actually claim it yet, an
awaits answer badge when its holder needs a human decision, and a magenta
reviews #4 badge when a task is somebody's
review — which decides who can take it, and is exactly
the thing the title cannot tell you. Verdicts mark the
cards they judged: a green upheld on done work a review signed
off, a yellow sent back on work that is open again because one
did not.
The board as a log
The TUI is a screen you sit at. hird events is the same view for
everything that cannot sit at one — a second terminal, a tmux pane, a CI job,
a script, or another agent:
$ hird events --follow
14:31:52 #1 claimed codex:9f2c Port the config loader
14:32:07 #1 witnessed codex:9f2c saw src/config.rs change
14:33:41 #2 claimed claude-code:af31 Audit the config loader
14:36:09 #1 completed codex:9f2c ported; env still wins over the file
Nothing new is recorded to serve it. Every mutation in hird already lands one
row in an append-only trail — hird show replays one task's slice
of it as history — and this is the same trail read sideways: across tasks, in
the order things happened. One shot prints the last 30 and stops.
--follow keeps reading at the TUI's cadence, and sweeps expired
leases and the working tree on the way, so an expiry or a
witnessed change lands in the feed even while no agent
is calling and nobody has the board open. Stopping the tail costs nothing and
forgets nothing, because the cursor is the trail itself.
--kind claimed,completed narrows to the events you are waiting
on, --task 7 to one piece of work,
--actor codex:9f2c to one agent's doings, and
--all-projects widens to everything in the database. And because
a feed for machines should not make them parse columns, --json
makes each line one object:
$ hird events --json --kind ground_shifted,contended --follow
{"cursor":41,"at":"2026-08-11T14:36:09.120Z","project":"/home/you/app","task":2,
"title":"Port the repository layer","actor":"codex:9f2c","kind":"ground_shifted",
"detail":"task 1 (Design the storage schema) sent back by review 5"}
That line is hird's whole integration surface as an emitter: pipe it into
jq, a log file, a dashboard, or the thing that pages you. The
dispatch hook answers wake somebody when
work appears; the feed answers everything after that — what the swarm
did, as it does it, in a form both halves of your tooling can read.
Seeing the graph run
The TUI shows the board; it cannot show the shape of the plan. For that there is a picture, live, in a browser:
$ hird web
hird web: http://127.0.0.1:7473/
One column per dispatch wave, an arrow for every dependency, the finished
ground on the left, and on each node its holder, lease countdown, what it
waits for and what it requires. Nodes light up as harnesses claim them, and
the trail streams underneath as it lands. Click a node and the panel beside
the graph answers hird show and hird show --why for it.
Narrow the picture to one plan from the header, and drag the
replay slider to see the board as it stood at any moment
since the trail began — the same fold hird ls --at prints, drawn.
A viewer, not a transport. hird web has the
TUI's posture and nothing more: it binds 127.0.0.1, it is
read-only — every route is a GET, and the page has no button
that writes — and it dies with the terminal. No agent ever talks to it;
harnesses reach the queue over MCP on stdio exactly as before, and the page
reads the same SQLite file the TUI does. While it runs it sweeps expired
leases at the board's cadence and announces them to the
dispatch hook, so a board left open in a browser
tab never swallows a summons. --port picks another port
(0 lets the system choose and the banner reports it),
--bind another address, --all-projects the whole
database.
The picture is drawn from data you can have as well: hird graph
--json prints the board as one object — every unfinished task with
its wave, every finished one that live work still builds on,
waits_for and feeds on each, the plan and node
name it was filed under, and the recess if one stands — and hird
graph --plan <name> narrows any rendering, waves, DOT, Mermaid
or JSON, to the tasks one plan filed and the dependencies between them.
MCP tools
Twelve, and no more.
| Tool | Purpose |
|---|---|
task_list | What work exists, optionally filtered by status — marking the ones your harness cannot claim. |
task_get | One task in full: dependencies, file scope, recent history. |
task_next | Be handed the next workable task, already claimed. |
task_claim | Take a named task. Atomic; fails if someone else has it, or if it reviews your own harness's work. |
task_scope | Say which files you will change; find out who else is in them. |
task_update | Record progress and renew the lease. Holder only. |
task_split | Break a task into pieces the other agents can work. Holder only. |
task_complete | Finish, with a summary — and on a review, a verdict. Holder only. |
task_fail | Give up, with a reason. Holder only. |
task_release | Hand the task back unfinished; with question, park it until hird answer. Holder only. |
mem_store | Record one durable fact — or, said again word for word, confirm one. |
mem_search | Find facts recorded earlier, by anyone, each marked with whether its code has moved since. |
Results are compact JSON. Failures come back as isError text
rather than protocol errors, so a model can relay them to you as-is instead of
reporting that a tool broke. There is deliberately no tool for filing
arbitrary new work: agents split what they hold, and everything else is filed
by you. Recall did not need a thirteenth tool either — it
rides along with the claim — and neither did
footing, which rides along with every fact hird serves,
or the footprint, which rides along with every
check-in and every finishing call.
The verdict is a parameter on task_complete,
not a thirteenth tool. Something an agent has to know to ask for is something
it will not ask for.
Configuration
The database lives at
${XDG_DATA_HOME:-~/.local/share}/hird/hird.db. Point hird
somewhere else with --db (highest precedence) or
HIRD_DB — pointing it at a throwaway file is how you try things
without touching your real board.
Optional config at ${XDG_CONFIG_HOME:-~/.config}/hird/config.toml:
# How long a claim survives without a task_update. Default 15.
lease_ttl_minutes = 15
# Whether list and search span every project by default. Default false.
# Explicit --all-projects / all_projects arguments still win.
all_projects_by_default = false
# What to do when a task declares files another agent is already working:
# "report" record it and tell the agent who else is in there (default)
# "refuse" reject the claim outright, rolling back anything it took
path_conflicts = "report"
# What a finished dependency under an unfinished review does to its dependents:
# "clears" workable at once, told the ground they build on is provisional
# (default)
# "holds" unclaimable until the review delivers its verdict
under_review = "clears"
# Whether task_next passes over tasks whose files overlap live work. Default
# true: when the queue gets to choose, it should not choose a collision.
dispatch_avoids_conflicts = true
# How many recalled assertions a claimed task arrives with. Default 5, and 0
# turns recall off. Keep it small: this lands in an agent's context unasked.
recall_limit = 5
# Whether the queue watches the working tree to see what tasks actually
# change. Needs git, goes quiet by itself where there is none, and costs
# nothing while no task in the project holds a lease. Default true.
witness = true
# Whether an assertion remembers the files it was learned against, so a later
# reader is told when that code has moved. Rides on the same working-tree
# access as `witness` and is off wherever that is. Default true.
memory_footing = true
# Whether the witness keeps the content of the versions it fingerprints, so
# `hird diff` can show what a task changed, reviews carry the diff of the
# work under judgement, and `hird salvage` can recover a version an
# overlapping write discarded. Rides on `witness` and is off wherever that
# is. Default true.
exhibit = true
# A command to run, detached, whenever a task becomes claimable — see
# "The dispatch hook" above. Empty (the default) means no hook.
dispatch_hook = ""
# Its twin for the other audience: run, detached, the moment a task raises a
# question and steps out of dispatch to wait on a human. Empty (the default)
# means no hook.
question_hook = ""
Unknown keys are an error rather than a shrug, so a typo is reported instead of silently ignored. Agents are told the configured TTL in the MCP handshake and asked to check in at half that interval, so raising it here is all that is needed to give slow tasks more room. The annotated example lists every key with its default.
| Variable | Meaning |
|---|---|
HIRD_HARNESS | This session's harness name. Set it in the MCP registration. Unset, the name the harness’s client gives for itself is used instead. |
HIRD_CAPABILITIES | Comma-separated capabilities this MCP session can satisfy. hird register --capability writes it. |
HIRD_PROJECT | Override project detection. |
HIRD_DB | Override the database path. |
Projects
Every task and assertion is filed under a project — the canonical path of your
repository root, found by walking up to the nearest .git. Two
harnesses opened in the same checkout share a queue; a different checkout gets
its own. One database holds them all, and --all-projects (or
p in the TUI) looks across everything.
Examples
The examples
folder holds runnable versions of everything on this page. Each script
points HIRD_DB at a temporary file, so running one cannot disturb
your real queue.
| File | What it shows |
|---|---|
manual-dispatch.sh |
Filing tasks and handing them out by number — the by-hand workflow, end to end. |
swarm-plan.sh |
A dependency graph with file scopes, then hird graph and simulated concurrent task_next dispatch. |
plan-file.sh |
The same graph as a file: read before it is filed, applied in one transaction, then edited and applied again. |
plan.toml |
The plan format, annotated, every field at work. |
witness.sh |
Two agents, one checkout, one file — the witness catching a lost edit while there is still time to act on it, and a third task that finishes read-only. Makes its own throwaway git repository. |
exhibit.sh |
A finished task's uncommitted diff shown after the tree has moved on, and a written-over version brought back — the exhibit, end to end. Makes its own throwaway git repository. |
tenure.sh |
An agent leaves uncommitted edits and vanishes; the successor's own claim says whose leavings it is standing in, and round one stays diffable after round two writes over it — the tenure. Makes its own throwaway git repository. |
question.sh |
An agent reaches a decision it must not guess, the task stays out of dispatch until the human answers, and the answer rides in the next claim. |
footing.sh |
A fact recorded against a file, that file rewritten, and every later reader told so — plus the way back to firm. Makes its own throwaway git repository. |
review.sh |
Work that files its own review when it finishes, scoped to what actually moved and barred to the harness that moved it. Makes its own throwaway git repository. |
verdict.sh |
The sent-back loop: a verdict reopens the work carrying the findings, the redo files a fresh review, and hird agents --record keeps score. Makes its own throwaway git repository. |
dispatch-hook.sh |
The dispatch hook hearing about every task that becomes claimable — filed, unblocked, review filed, sent back — with a log file standing in for the multiplexer. |
recess.sh |
The queue stood down: claims refused in the human's words, task_next told to stand by rather than "the queue is empty", the dispatch hook quiet, and everything that waited announced on hird resume. |
events.sh |
The board as a log: a follower tails the trail while two harnesses work, then the same record is read after the fact, filtered by kind, and emitted as JSON. Makes its own throwaway git repository. |
protocol.sh |
MCP 2026-07-28 on the wire: server/discover, a task worked without ever calling initialize, and a harness that never set HIRD_HARNESS named by its own client. |
task-body.md |
A task body worth writing, for --body-file. |
config.toml |
Every configuration key, annotated, at its default. |
harness/ |
Drop-in MCP registration for Claude Code, Codex CLI, Copilot in VS Code, the Copilot CLI, the Gemini CLI and OpenCode — what hird register writes, for reading or for pasting somewhere it does not reach. |
lib/mcp.sh |
The shell helpers the scripts share — a raw MCP session in twenty lines, and the stateless 2026-07-28 form beside it. |
FAQ
Can I still dispatch tasks manually?
Yes. hird add then "pick up task 42" is unchanged, and
task_claim is a first-class tool, not a fallback. Automatic
dispatch is something an agent opts into by calling task_next;
if you never ask an agent to work the queue, it never will.
Can hird start or prompt agents itself?
Not directly — hird has no daemon and does not own any terminal — but it will
tell something that does. Set the dispatch hook
and hird runs your command whenever a task becomes claimable; point that
command at a terminal multiplexer that can address agents, such as
herdr (herdr agent prompt …),
and idle agents get summoned to pull work the moment it exists. hird decides
what is workable and who may take it — and hands the hook
that second fact too, as HIRD_RECUSED, so a summons for a
review can be routed away from the harness that wrote the work; the
multiplexer owns where agents run. Neither replaces the other.
Under herdr the pairing is one install:
herdr plugin install aoprisan/hird/herdr-plugin — see
herdr-plugin/.
Do I have to declare dependencies or file scopes?
No. Both are optional. A task with no --needs is workable
immediately, and a task with no --path simply never reports an
overlap — you lose collision detection for that task, not the ability to
dispatch it.
What happens if an agent dies mid-task?
Its lease lapses after lease_ttl_minutes and the task returns to
open by itself. There is nothing to restart and nothing to clean
up. To take it back sooner, hird reopen it.
Can two agents work the same file?
Nothing stops them at the filesystem level — hird is a coordinator, not a lock
manager. What it does is make the overlap visible to both agents and to you
the moment it is declared, refuse to choose a colliding task during
automatic dispatch, and, with
path_conflicts = "refuse", reject the claim outright.
And when they do it anyway, it tells them. A declared overlap that the working tree confirms — the file has moved, and the two of them disagree about what it now says — is reported to both by name, with the one instruction that saves the work: re-read it before you write.
Can I have one agent check another's work?
That is what recusal is for, and hird is the only thing
positioned to enforce it: a harness cannot see another harness's session, so
an agent handed "review this" cannot tell whether it wrote it. hird recorded
who held the lease. Mark work --review and finishing files the
review itself, scoped to what actually moved — and the queue refuses it to the
harness that did the moving, in the same transaction as the claim.
Does the memory go stale?
It does, and hird is the part that notices. An assertion is stored with its
footing — the files it was read off and what they said
at the time — so a fact whose code has since been rewritten arrives marked
shaky instead of arriving indistinguishable from one recorded
this morning. hird never calls it false; a rename and a rewrite look the same
from here. It tells you which facts a re-read would pay for, and
hird mem standing lists them.
Nothing has to be curated. Confirming a fact is the ordinary act of stating
it: mem_store the same sentence word for word and the original is
re-anchored to today's code rather than duplicated, with you recorded as
another voice for it.
Does hird know which agent changed a file?
No, and it says so rather than guessing. One checkout has one filesystem and
no keyboards, so a file that moves while three agents hold leases lands in all
three footprints. That is why changed is always described as what
moved while the task was held, and why the sharp warning needs a
declared scope on both sides — the declaration is the part that says who
intended to write.
Is there a server to run?
No. Every front end opens the same SQLite file in WAL mode. Start a harness
and it spawns its own short-lived hird mcp; close it and nothing
is left behind but the database. hird web
listens on loopback while you have it open, exactly as hird tui
holds the terminal — it is a screen, not a service, and nothing depends on
it being up.
There is one you can run on purpose, for harnesses that are not on
your machine at all: hird-server serves the same queue over MCP
on HTTP, with a roster file saying which bearer token connects as whom.
How the central queue works walks a request through
it — token to person, and two agents racing for one task — with the server
simulated in the page. Nothing local depends on it; the local queue never
learns to speak HTTP.
How do I start over?
Delete the file that hird db-path prints, or point
HIRD_DB at a new one. Schema migrations run on open, so an
existing database is upgraded in place when you install a newer build.