one binary no daemon SQLite (WAL) MCP over stdio

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.

Manual dispatch is not going anywhere

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."'
VariableMeaning
HIRD_EVENTWhy 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_TASKThe task number.
HIRD_TITLEIts title.
HIRD_PROJECTIts project root.
HIRD_RECUSEDHarnesses 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_DBThe 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 — hird was watching, and nothing moved. On a task still being worked it reads read-only so far: a running total, not a verdict.
  • nothing at all — hird was 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:

BecauseThe why line reads
it was recorded on this task, on an earlier passrecorded while working this task earlier
it was recorded on a task whose files overlap yourslearned on task 4 (…), working src/config.rs
it reads like your titlementions "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
CommandWhat it is for
addFile a task. Prints the number, which is how you and the agents refer to it.
lsThe 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.
showOne 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.
diffThe 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.
salvageRecover 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 / reopenHuman overrides. They work regardless of who holds the task.
answerResolve the question holding an open task out of dispatch; the answer is handed to its next claimant.
recess / resumeStand 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.
depAdd or remove "must wait for" edges after the fact.
planFile 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.
graphThe 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.
scopeShow or set the files a task is expected to touch. Warns about overlaps.
requireShow 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.
agentsWho 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.
eventsThe 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.
memRecord 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.
tuiThe live board.
webThe live board as a picture in a browser — the graph, running. Loopback, read-only, gone when the terminal is.
mcpServe MCP on stdio. Harnesses run this, you generally do not.
registerWrite 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-pathPrint 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

Tabnext screen (Shift-Tab for the previous)
ptoggle project filter (current / all)
/filter or search
?help
qquit

Queue board

j kmove down / up
h lprevious / next column
g Gfirst / last card
Enteropen the task and its history
aadd a task (Tab for the body, Enter to save)
Aanswer the selected task's question
c rcancel / reopen the selected task

Memory browser

j kmove down / up
Entershow the assertion and its provenance
dsupersede it with something truer
sshow or hide superseded assertions
fonly the facts whose files have moved since

Swarm

j kmove between agents
Enteropen the task that agent is holding

Graph

j kmove within a wave
h lprevious / next wave
Enteropen 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.

ToolPurpose
task_listWhat work exists, optionally filtered by status — marking the ones your harness cannot claim.
task_getOne task in full: dependencies, file scope, recent history.
task_nextBe handed the next workable task, already claimed.
task_claimTake a named task. Atomic; fails if someone else has it, or if it reviews your own harness's work.
task_scopeSay which files you will change; find out who else is in them.
task_updateRecord progress and renew the lease. Holder only.
task_splitBreak a task into pieces the other agents can work. Holder only.
task_completeFinish, with a summary — and on a review, a verdict. Holder only.
task_failGive up, with a reason. Holder only.
task_releaseHand the task back unfinished; with question, park it until hird answer. Holder only.
mem_storeRecord one durable fact — or, said again word for word, confirm one.
mem_searchFind 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.

VariableMeaning
HIRD_HARNESSThis session's harness name. Set it in the MCP registration. Unset, the name the harness’s client gives for itself is used instead.
HIRD_CAPABILITIESComma-separated capabilities this MCP session can satisfy. hird register --capability writes it.
HIRD_PROJECTOverride project detection.
HIRD_DBOverride 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.

FileWhat 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.