A kanban board for the work you do with coding agents, stored as markdown files
inside your repository.
Hand a coding agent one task and it reliably becomes five. Within a session that
is fine. Across sessions it is not: what each sub-task was for and where it got
to both evaporate, and there is no artifact left to reconstruct them from.
jaira makes that state durable and visible. Tickets travel with the code: a
teammate clones, runs jaira fetch, and sees the same board. No server, no
accounts, no setup.
repo/ ~/.jaira/
├── .jaira/ ├── lanes/ your custom lanes
│ └── tickets/*.md ├── projects.json
└── src/ └── state/<worktree>/ sessions, locks
One window over every project, with the whole flow on one screen: a dot per
ticket, agents counted per step, and the arrow lit where work just moved:
The repository holds only tickets. Everything ephemeral (what each session is
focused on, write locks) lives under your home directory, so .jaira/ never
mixes committed content with scratch state.
Install
curl -fsSL https://raw.githubusercontent.com/BeMuCa/jaira/master/scripts/install.sh | sh
That downloads the latest release for your OS, verifies its checksum, and puts
the binary in ~/.local/bin (override with JAIRA_INSTALL_DIR). On Windows,
in PowerShell:
irm https://raw.githubusercontent.com/BeMuCa/jaira/master/scripts/install.ps1 | iex
That does the same for Windows: jaira.exe goes to
%LOCALAPPDATA%\Programs\jaira (override with JAIRA_INSTALL_DIR), which it
adds to your user PATH — no admin rights, and running it again updates in
place. Or build from source:
go install github.com/BeMuCa/jaira/cmd/jaira@latest
Or download a binary from the releases page and put it on your PATH. Either
way it is a single static executable with no runtime dependency: nothing to
install alongside it, no daemon, no database.
git is optional. jaira works fine in a directory that is not a repository; you
only lose the parts that are about sharing (jaira share, the merge driver).
Upgrading
jaira self upgrade
Replaces the binary in place with the latest release, verifying its checksum
first, the same way the install script does. --check reports what is
available without installing anything; --version vX.Y.Z installs that exact
release, including going back to an older one. A Homebrew or go install
build is refused with the right command for that install instead — jaira
never overwrites a file another tool owns.
jaira checks for a new release at most once a day, in the background, and
never on the command path: nothing you run ever waits on the network for
this. The result shows up as a small status line in the footer of the
launcher and the board — jaira v0.1.0 · up to date or
jaira v0.1.0 · v0.2.0 available — run: jaira self upgrade — never as a
line from a CLI command, which stay quiet so a script or an agent driving
jaira is never interrupted by it. Set JAIRA_NO_UPDATE_CHECK=1 to turn the
check off entirely; the version still shows, just without the "up to
date"/"available" half.
This is different from jaira update, just below, which is about a
repository's board setup and downloads nothing.
If jaira --version does not show the version you just installed, you have
two copies: the install script writes ~/.local/bin, go install writes
~/go/bin (or $GOBIN), and whichever comes first on your PATH runs.
which -a jaira lists them; keep one.
Bringing a board up to date
A newer binary on an older board says so on every command:
this board was set up by an older version of jaira — run 'jaira update'.
Run it in the repository:
jaira update
It rewrites the jaira section of CLAUDE.md/AGENTS.md for this board's
lanes and lists what changed since the version that last ran there. Commit
that section along with your next change, or leave it — it is only the agents'
copy of the rules.
No update path changes whether the board is shared. jaira update,
jaira self upgrade, go install of a newer build, the first command after
the binary changed, and jaira init run again all leave .gitignore exactly
as it was: a shared board stays shared, a private one stays private. Only
jaira share and jaira share --undo change it.
Start
cd your-repo
jaira init # creates .jaira/, private and gitignored
jaira # opens the board
A board starts private. init gitignores it, so your tickets are yours alone.
When you want the team to have them:
jaira share
git add .jaira .gitignore && git commit -m "share jaira board"
Publishing is a decision rather than a default, because the tool cannot know whether your
notes are ready to be read by everyone who can clone the repository. jaira share --undo makes it private again; nothing about the tickets changes either way, so
it is not a migration.
A teammate then joins with nothing but the README:
git clone <the repository> && cd <it>
# install jaira as above, then:
jaira init # sets the board up on this machine; a shared board stays shared
jaira fetch # brings in the tickets that travel on their own refs
jaira # opens the board
init is needed even though .jaira/ came with the clone: git keeps no empty
directories, so a fresh clone holds only .jaira/.gitattributes, and every
other command answers run 'jaira init' until it has run. On a board that is
already in git it changes nothing in .gitignore and says
This board is shared. The field-aware merge driver binds itself on the first
command after that. Later, when someone has upgraded jaira, git pull,
upgrade your own binary (see Upgrading) and run jaira update.
The team flow is pull-based. A captured ticket belongs to nobody; whoever pulls
it out of the backlog becomes its assignee in the same move. Pull before you
pick, push after you pick, and a teammate's claim shows on the card as an
@-marked name in a colour of its own.
A ticket
---
id: 01KZSAMZMANMNFNHS05J0VAG6T
title: Fix session cookie dropped on 302
status: in-progress
ready: true
creator: berk
assignee: berk
executed-by: haiku
goal: Session must survive the OAuth round-trip
context: |-
Reported in chat while debugging Safari logouts. The cookie is dropped
cross-site on the OAuth redirect, so users are silently logged out
mid-flow. Reproduced on Safari only.
blocked-by: []
commits:
- 4f2a1c9
model-tier: cheap
outcome-what: Set SameSite=Lax and re-issued the cookie on 302
outcome-why: The cookie was dropped cross-site, silently logging users out
outcome-resolves: >-
The DoD asked for survival across the OAuth round-trip; re-issuing on
redirect closes the gap, verified by session_test.go
created-at: 2026-08-11T21:11:27Z
updated-at: 2026-08-11T21:14:03Z
updated-by: berk
---
# Fix session cookie dropped on 302
## Definition of Done
- [x] session survives the OAuth round-trip
- [x] covered by a test
- [ ] reviewed and merged
The file format is the API. It is hand-editable, and writing one field rewrites
only that field's bytes, so a lane change shows up in git as a one-line diff, not
a reformatted file.
Three details are load-bearing:
assignee is always a human, even when an agent does the work. The model is
recorded separately as executed-by. Ownership of an outcome does not transfer
to a language model.
outcome-resolves is not a restatement of what changed. It is the argument
that the change satisfies the definition of done, enough to review without
opening the code.
- The definition of done is a checklist in the body, not a frontmatter string,
because that is how acceptance criteria are actually written. It also earns its
keep at the terminal lane: a ticked box is a human editing a file, which is
evidence a model asserting "it works" cannot manufacture.
mode: says how the ticket is worked rather than what is in it. Empty —
the default — means the lanes run autonomously. conversational means a
person found open design decisions on this ticket and is reading along: the
agent shows its diff after every definition-of-done item and, when the lane
changed code, hands back a commit line instead of committing — a lane that
changed none hands back nothing, as outside the mode. It is written with
jaira set <id> mode=conversational, takes no other value, and nothing clears
it automatically — a person does, with jaira set <id> mode=.
external: is reserved for a future Jira/YouTrack adapter. jaira never
interprets it and never rewrites it.
The lanes
BACKLOG → TODO → PRE-PROCESS → IMPLEMENTING → HITL → REVIEW → HUMAN REVIEW → DONE
│ ▲
└ needs you ────────┘ + BLOCKED
Anything can be thrown into the backlog. Nothing leaves it without a goal, a
definition of done, the context it came from, and an assignee.
That gate is the point of the tool. Capture should be frictionless and execution
should not be: an agent that starts work with no checkable target produces work
you cannot review. Acquiring a definition of done is the price of admission to a
run.
REVIEW is a human checkpoint: no agent may move a ticket on out of it —
sending it back to an earlier lane is allowed, since that accepts nothing. The
review agent writes its verdict and stops; you accept the work in the board, or
raise a follow-up ticket that carries the context across and links back.
DONE requires every checklist item marked done, and the plan finished too, if
the ticket has one, since the criteria cannot have been met while the work that
meets them is still in progress. It also requires the commits that carry the
change recorded on the ticket: the requirement sits where work is accepted, so
a ticket can move through review before it is committed, but nothing is
accepted that cannot be checked.
BLOCKED refuses a ticket that cannot say what it is waiting on. Pass
--reason "..." on the move, or record the blocking ticket in blocked-by,
which counts as the answer.
A review agent cannot certify its own work. This is not politeness about AI: LLM-as-judge
is measurably poor at catching real defects, so a terminal
state gated on a model's own assessment would mean nothing. There was once a
--signal flag that accepted free text as evidence and never checked it; it was
removed rather than repaired.
Lanes are pipeline stages
A lane can bind a prompt, a model tier, and an input/output contract. That turns
the board from a progress display into a pipeline you can watch: a cheap model
implements, an expensive one reviews.
A board is its lane directory. Whatever lane files .jaira/lanes/ holds
are the board — all of it, in the order of the order file beside them, and
nothing is added underneath. A new board gets its files written on first use:
your default board's selection, or the ten shipped lanes. Remove a lane and its
file goes; the shipped lanes stay on offer in the catalogue, and jaira lanes add brings one back. The directory is yours and gitignored, so two people on
one shared board can run two different boards.
Custom lanes are single files in ~/.jaira/lanes/, the catalogue, so sharing
one is sending someone the file.
Lanes other people made live in this repository's lanes/
directory — the marketplace, which is nothing more than that directory.
jaira lanes market lists them; jaira lanes market adopt <id> puts one in
your catalogue; jaira lanes add <id> puts it on a board. To add yours, open a
pull request with one file under lanes/. CI parses every file there, so a
lane that does not load fails the build rather than the person who adopts it.
Read a lane before you adopt it: adopting means running its prompt at the model
tier it declares.
---
id: critique
name: Critique
after: review
precedence: 55
agentic: true
model-tier: strong
input-requires: [goal, definition-of-done, outcome-what, diff]
output-produces: [outcome-resolves]
---
# Prompt
Look for defects the implementer would not have noticed...
Ask the tool to assemble the input rather than letting the agent decide what
context matters:
jaira show JJN9KH --for-lane critique --json
That returns the prompt, only the declared fields, and the diff of the ticket's
own commits. If the agent chose its own context, the contract would be a
suggestion.
Both agent files (CLAUDE.md, AGENTS.md) carry a generated block naming this
board's lanes in order, any loop a lane declares, and for each lane whether an
agent may work it at all. It also says the thing that is easy to assume wrong:
nothing runs by itself. A lane's prompt fires because a session ran it, and
jaira next --per-lane is how that session finds the lane with work waiting.
That block follows the board rather than waiting to be refreshed: adding,
adopting, removing or reordering a lane rewrites it, from the CLI and from the
settings screen alike. A lane file edited by hand goes through neither, so
jaira validate reports when the block and the lanes have drifted apart. Text
after a jaira:local marker you add inside the block survives every rewrite, so
a project's own rules live with the note instead of contradicting it from
outside.
A teammate without your critique lane still sees those tickets, in a read-only
passthrough column. Hiding them would be the worse failure.
Tickets travel on their own git refs
Hand someone a ticket today and they learn nothing: the ticket file lives in
the branch of whoever wrote it, and if that branch is unpushed, stale or
force-pushed, the assignee sees neither the title nor the id. Scanning every
branch for ticket files is expensive and still wrong.
So every write also puts the ticket on a ref of its own, and a milestone —
the file that names which tickets belong to one round of work — travels the
same way, for the same reason: it has to reach everybody without waiting for a
branch to be merged.
A milestone's ref is named after its ID, a random six characters, and the
name is only the file name in its tree: renaming one is an ordinary write on
its one ref, so two people renaming and creating at once leave two milestones
and never none.
refs/jaira/tickets/<id> one ref per ticket, its tree carries <id>.md
refs/jaira/milestones/<ID> one ref per milestone, its tree carries <name>-<ID>.md
Those refs belong to no branch. The default refspec does not fetch them, so a
teammate who does not use jaira sees none of it, and they never show up as a
branch or in a hosting provider's web UI. The tree carries the whole file
rather than just an id or a name, so the other side reads it with no checkout
and nothing to merge:
jaira fetch # one round trip, no branch
git show refs/jaira/tickets/<id>:<id>.md # or read it by hand
jaira fetch marks each card @you, new, and ref-only — the last being
the property that matters: this ticket is in no branch you have. A ticket that
has just become yours also raises a desktop notification, once, and the board
fetches on its own every minute.
Two containers, two hostnames, two sets of branches, and nothing in common but
the remote. Nobody pushed a branch and nobody sent a message.
The push is a compare-and-swap. Each ref commit takes the SHA you read as
its parent, so a stale write is a non-fast-forward that git rejects by itself,
and --force-with-lease covers the first write, where no commit has a parent
to compare. A race is detected on the spot rather than three days later at a
merge, and the message names who was quicker and where the ticket now is.
Writing works offline. A write is queued, not pushed: sending happens
after the command, and only in a process that actually wrote something, so
jaira list never waits for a remote. If there is no route, the ticket is
correct locally, the card says unsent, and it goes out with your next
command. This is also why the board and the git merge driver can write tickets
at all — neither may block on a network.
The ticket file stays in the code commit as well, and the two answer
different questions:
|
File in the commit |
Ref |
| Answers |
what this change was, and why |
where the ticket stands now, and with whom |
| Read by |
the reviewer of the diff |
the board, notifications, another machine |
| Changes |
once, with the code |
on every move, claim, note |
| Lives |
permanently, in the history |
while the ticket is on the board |
On the board it is one story per ticket, not two panes to compare: the local
file and the ref are merged field by field by the same resolver the merge
driver uses (see Concurrency below), with the ref's parent blob as the
merge base. Progress is never reverted — a local edit made a minute ago cannot
drag a ticket back out of review because a reviewer touched it first.
~/.jaira/settings.json holds the three choices this needs, all optional:
{ "remote": "origin", "notify-off": false, "hook": "/path/to/script" }
"remote" is a default for every board opened on this machine, not an order.
A remote name belongs to one clone — the repository this machine knows as
upstream is origin to somebody who cloned it directly — so the answer for
one board lives in that clone's own git config:
git config jaira.remote upstream
The order jaira resolves it in:
git config jaira.remote in the clone. Set for this repository, so it is
used as given and never falls back; if that remote is gone, the ref command
stops and names it. Shared by every worktree of the checkout, and never
committed, so the next person's clone is not told what to call things.
"remote" in settings.json, when this repository actually has a remote by
that name.
- The repository's only remote, when it has exactly one. A single-remote
checkout therefore works with nothing configured, even while
settings.json
names a remote it has never heard of.
- Otherwise the command stops. Several remotes and none of them the one asked
for is ambiguous, and guessing would sooner or later push a ticket ref into
a fork, where nobody would look for it.
The hook is called on move and claim for delivery that does not wait for
the other side to fetch. jaira only calls it and brings no dependency of its
own: the script gets JAIRA_EVENT, JAIRA_TICKET, JAIRA_TITLE,
JAIRA_STATUS, JAIRA_ASSIGNEE, JAIRA_ACTOR and JAIRA_ROOT in its
environment, and what it does with them — a Slack webhook, ntfy.sh, Telegram —
is yours.
jaira hook example prints a working script for that field, so the first one
does not have to be invented: jaira hook example > ~/.jaira/notify.sh && chmod +x ~/.jaira/notify.sh, then point "hook" at it. As printed it rings the
terminal bell — twice for a lane that belongs to a person, once for a finished
ticket, and not at all for the lanes an agent works, because only a state in
which nothing moves without you is worth a sound. It installs nothing and needs
nothing installed; the script marks the lines to replace to deliver elsewhere.
The fork limit, stated plainly: refs do not travel across forks. Everyone
taking part pushes to the same board repository, which means participation
requires push access to it. A contributor without push access keeps the ticket
file in their branch and their pull request; they lose the ref channel, not the
board. A board in a directory that is not a repository, or with no remote,
simply does not use refs and behaves exactly as before.
What git is actually doing
No part of this is a protocol of its own. Every step is a git command you could
type yourself, which is the reason it works against GitHub, GitLab or a bare
repository on a shared drive without any of them knowing what jaira is.
| Step |
The git underneath |
| write a ticket to its ref |
hash-object -w, mktree, commit-tree — a commit whose tree holds <id>.md, with the SHA you read as its parent |
| send it |
push --force-with-lease=<ref>:<the SHA you read> — refused if anybody wrote since |
| see what exists |
ls-remote origin 'refs/jaira/*' — the whole board, tickets and milestones, in one round trip |
| collect it |
fetch --prune origin '+refs/jaira/*:refs/jaira/*' — both namespaces in one refspec, or you get cards whose milestone file nobody has |
| read a ticket without a checkout |
show refs/jaira/tickets/<id>:<id>.md |
| read a milestone without a checkout |
ls-tree --name-only refs/jaira/milestones/<ID>, then show refs/jaira/milestones/<ID>:<name>-<ID>.md |
| a ticket's history |
log refs/jaira/tickets/<id> — every move it ever made |
| has it landed |
rev-list -1 <branch> -- .jaira/logbook/*/<id>* .jaira/archive/<id>* |
| the backup branch |
the same three plumbing commands, one tree with every ticket in it |
Two consequences worth stating, because both are load-bearing:
The compare-and-swap is git's, not ours. A ref update is atomic at the
hosting provider, and --force-with-lease refuses when the ref moved since you
read it. That is the whole concurrency mechanism: no server decides who wins,
the remote's own ref lock does.
Nothing here is visible to anyone who does not want it. refs/jaira/* is
outside refs/heads, so the default refspec never fetches it, it is not a
branch, and it does not appear in a hosting provider's web UI. A teammate who
does not use jaira clones the repository and sees an ordinary project.
How two people hand work over
Ada writes a problem down and says nothing about who should fix it. Grace looks
at the board because she felt like it, decides to take it, and pulls. Ada looks
again and the board tells her. Not a word was exchanged.
A ticket nobody is working lives on its ref and nowhere else. Pulling it is
what puts it on your disk, and that pull is a compare-and-swap, so exactly one
person can be holding it:
sequenceDiagram
autonumber
actor A as Ada
participant R as origin<br/>refs/jaira/tickets/*
actor B as Grace
A->>R: jaira create "fix the 302" --assignee grace
Note over A: no file on Ada's disk:<br/>the ticket is on its ref
B->>R: jaira fetch
R-->>B: "fix the 302 @you new ref-only"
Note over B: a desktop notification, once
B->>R: jaira pull (assignee = grace, compare-and-swap)
R-->>B: accepted
Note over B: now, and only now, the file<br/>exists under .jaira/tickets — here
A->>R: jaira pull (same ticket)
R-->>A: refused: "grace has it"
Note over A: nothing written on Ada's disk
B->>B: work, commit the ticket with the code
B->>R: jaira move --to review
B->>R: jaira release (optional)
Note over B: assignee cleared, file removed:<br/>anybody can pull it again
Reading the board needs no pull: jaira list, jaira next and jaira show
include tickets that are still on their refs and mark them [pull it]. Every
write refuses them with exit 3 and says which command changes that — a ticket
you have not pulled is one you are not holding, and half-writing it is the one
thing this rules out.
What each step buys
| Step |
Why it is that way |
create writes only the ref |
While nobody is working it, a file in somebody's checkout is a copy a merge can duplicate, and it hides who the ticket belongs to |
| an assignment does not materialise anything |
Assigning reserves the ticket; the assignee still pulls it themselves, and which branch they work in is not the assigner's business |
pull pushes first, writes the file second |
The other order leaves the loser of a race holding a file that belongs to somebody else |
the ref's assignee gates the pull |
The compare-and-swap only rules out two writes in the same instant; it cannot say "this is not yours", because a pull re-reads the ref right before writing |
release clears the ref, then removes the file |
Without it an assignment is a reservation nobody can hand back |
The board on the right is on another machine, opened with jaira and nobody
touching it. A ticket filed on the left turns up as a card, with the line at the
bottom saying which one became yours.
A board with no remote behaves exactly as it always did: the file is the board.
That branch is decided in one place, so no command has to know which mode it is
in.
The backup branch, and when a ref dies
A ticket nobody is working lives only on its ref, so an untouched backlog lives
only on the remote. Every participant's clone holds the refs it has fetched, so
the board survives any one machine — but somebody cloning for the first time has
no refs at all. For them there is a branch:
jaira/board parentless, never merged, one commit per change
└── board/<id>.md the same ticket files, at a path of their own
It is built with git plumbing and never checked out, so a run cannot disturb
whatever you are in the middle of, and it is rebuilt from the current set of
refs each time — so a ticket that has gone is simply not in the next snapshot
and stays readable in the previous ones. git log jaira/board is the board's
history and git diff between two snapshots says what moved.
jaira snapshot # normally nobody runs this
It happens by itself, in a detached background process, when the last snapshot
is more than three days old — never on the command path, so nothing you type
waits for it. Three days is not thrift: the working state is always on the refs
and every clone has them, so this is the backup for the slow cases only. The
files live under board/, not .jaira/tickets/, because at the same path the
first accidental merge of this branch would collide with every working ticket at
once.
A ref is removed only once its ticket has arrived somewhere everybody can
see. Filing a ticket away — jaira logbook, jaira archive — writes its
final state to the ref and leaves it standing. At that moment the ticket file is
only in your branch, and taking the shared copy down too would hide the ticket
from everybody for as long as a review takes; somebody would notice the same
problem and write it down a second time.
The ref goes in the snapshot run, immediately after the write, for tickets that
are filed away in a landing branch. That order is the point: at the moment of
deletion the ticket is in the snapshot and in the branch it landed in, so
there is nothing left to lose.
{
"landing-branches": ["main", "develop", "release/*"],
"fetch-every": "10m",
"snapshot-every": "72h",
"landing-grace": "72h"
}
The three intervals are durations, so the same field says 3s for a screen
recording and 72h for a backup. An unreadable one falls back to the default
rather than refusing to open the board.
A list rather than one "main branch", because there is no answer to what the
important branch is called. With nothing configured, the remote's own HEAD is
used; if even that cannot be resolved, nothing is ever removed — a ref left
standing costs nothing, one removed by mistake takes away exactly the visibility
this is for.
A branch that never gets merged would otherwise keep its ref for ever, so
jaira fetch and jaira validate name any ticket that was finished here and
has not arrived after three days. jaira snapshot --drop <id> is the way out, and
it is deliberately a person's decision.
Concurrency
Two people moving the same ticket both rewrite the same status: line. Line-based
merging calls that a conflict, on the single most common operation the whole system
performs.
So jaira init registers a field-aware merge driver for the clone:
| Field |
Resolution |
status |
whichever lane is further along; never revert progress |
blocked-by, commits |
union; neither side's addition is lost |
| other scalars |
the more recent updated-at wins |
prose (goal, outcome-*, body) |
a real conflict, scoped to that field |
A conflicted ticket stays valid YAML: the contested value is parked in a
conflict-theirs-<field> key and listed under merge-conflicts, rather than the
file being filled with markers. Conflict markers would make the frontmatter
unparseable and blank the ticket on everyone's board until someone resolved it.
jaira resolve shows both sides and can take either.
Registration writes to .git/config, because git deliberately does not let a
clone configure an executable on your behalf. jaira says so when it does it rather
than acting quietly.
Known limit: merge drivers only run for merges git performs locally. A conflict
resolved through a hosting provider's web UI falls back to line-level markers.
Within one machine, concurrent CLI calls take a per-ticket lock and write
atomically, because a single write path prevents schema drift but does nothing
about two processes interleaving.
With Claude Code
Install the skill from .claude/skills/jaira/ and Claude will use the CLI
directly. Optionally install hooks/sync-tasks.sh as a PostToolUse hook on the
task tools to mirror Claude's task list into the backlog.
Mirrored tickets land in the backlog behind the gate. Lane movement is
deliberately not mirrored: letting an external status push a ticket into the
pipeline would route around the gate that makes the pipeline worth having.
The sync is idempotent. A task already mapped to a ticket updates it rather than
creating a second one, and an unchanged list writes nothing, so a
board→tasks→board round trip settles instead of oscillating.
jaira hook print emits a second, unrelated hook — unrelated to
jaira hook example above as well, which prints a script jaira calls, where
this one prints settings Claude Code reads: a Stop hook that refuses to end a
session while an agentic lane still holds work. A lane's prompt already
says where a ticket goes next, and jaira move now names the command that works
the lane it landed in — but both are advice a model can drop, and the hook is
the environment refusing. It fails open (no board, no jaira on PATH, a stop it
has already blocked once) so it cannot trap you in a session.
Roles: a teamlead, a dispatcher, and the workers
jaira roles install --global (or --project, so the roles arrive with a
clone) writes the agent prompts that drive a board into your skills directory.
--agent copilot writes them where GitHub Copilot reads skills —
.github/skills or ~/.copilot/skills — instead of Claude Code's; see
docs/AGENTS.md.
They are compiled into the binary, so they match the version you run. Run it
again after an upgrade: a prompt you have edited is left alone and reported,
and --force replaces it too.
| Skill |
What it does |
jaira |
Teaches the agent the CLI. Ships in .claude/skills/jaira/, not through roles install. |
/jaira-teamlead |
Plans with you and starts one dispatcher per ticket. Writes no code. |
/jaira-dispatcher <id> |
Carries one ticket lane by lane to the next lane a person owns, one worker per lane. |
/jaira-worker <id> |
Opt-in alternative to the dispatcher: carries the ticket through every lane itself, and only a lane marked context: fresh gets a new agent. |
/jaira-role-lane <id> <lane> |
Works exactly one lane, then stops. |
/jaira-role-brainstorm <idea> |
Turns an idea into tickets, or says it is not one. |
/jaira-role-research <question> |
Finds the facts out first, each with its source and confidence. |
/jaira-role-tester [id] |
Runs the tests and reports what fails. Fixes nothing. |
/jaira-role-acceptance <id>... |
Builds the acceptance page and reads your marks back. |
/jaira-role-pr <id> |
Pushes the branch, opens the pull request when you typed it, answers review comments. Never merges. |
/jaira-role-logbook-summary [period] |
Writes a report for a project lead, with hours. |
Which route fits which task, every role and the acceptance page, as a
walk-through in German: docs/how-to-work-with-jaira.html
(open it in a browser; GitHub shows the source).
/jaira-dispatcher <id> carries one ticket lane by lane, handing every lane
to a fresh worker and stopping at a lane a person owns. It needs no teamlead:
start it yourself for a single ticket. Under Herdr each worker gets a tab of
its own, which scripts/run-lane.sh closes once the worker has finished and
the ticket has left its lane.
/jaira-worker <id> is the same job done by one agent: it works every lane
itself and hands only a lane marked context: fresh in its lane file — on a
board where no lane says, the last agentic lane before the first lane a person
owns, review on the shipped board — to a new agent in a tab of its own.
Turn it on with "dispatch": "worker" in ~/.jaira/settings.json, or on the
board's settings screen (S, "Agents per ticket"); a teamlead then starts it
instead of the dispatcher. jaira roles list says which is set.
/jaira-teamlead is for several tickets at once: it decides the order, says
what is not worth doing, and starts one dispatcher per ticket.
/jaira-role-acceptance <id>... turns the tickets waiting for you into one
page to accept them on: the tests already run, setup written once per block,
and one scenario per block whose steps name the tickets they prove. You mark
each step ok or no; a ticket whose steps are all ok is accepted. Reading it
back, the role works out every "no" itself — back into the same ticket, a new
bug, or a wrong step — and publishes a fixed record of the acceptance. A
dispatcher or teamlead hands tickets on to it when they reach a human lane.
/jaira-role-logbook-summary [period] writes what a project lead wants to
know about a period — what is finished, what is under way, hours estimated
from commit times — as one page without code, every text ready to copy. A
.jaira/summary.md (or ~/.jaira/summary.md) says in your own words what
your reader's system needs: its markup, a comment per day, hours split by
customer.
- Every question either of them puts to you comes as a choice of two to four
options, the recommended one first, rather than a paragraph to answer.
jaira roles list shows the roles the binary you run carries.
A question goes up the chain and only the top asks you. A worker never asks:
it writes the question onto the ticket and stops. Its dispatcher passes it to
the teamlead that started it, the teamlead asks you, and your answer goes back
down as a note the next worker reads:
The cockpit: a Claude Code mod for the session you work from
jaira roles install also writes jaira-cockpit, a Claude Code mod, beside the
roles. Claude Code loads it by itself from the next session on — from
~/.claude/skills with --global, from the project's .claude/skills with
--project once you have trusted the workspace. Without that install nothing
changes for anybody.
- Agent reports and task notifications that name a ticket reach the chat as
one line, marked as shortened;
/cockpit report <id> shows them whole.
- Everything the mod shows sits in one frame above the prompt, titled
jaira-cockpit; its head line counts the workers and reviews at work (their
Herdr tabs, or this session's subagents) and the tickets waiting for you,
and shows the five-hour and seven-day windows used (off a subscription, what
the session cost). The mod keeps no status line of its own.
- The frame lists the tickets waiting in a lane a person owns that are yours
(their assignee is a name
jaira whoami gives you) or nobody's, with
Accept (moves it to done in the ticket's own worktree; offered only
where the next lane ends the board, and refused while an item of the
definition of done is open) and Give back (asks in a pane of its own
what is missing, notes it, moves it to in-progress and hands it to the
worker that carried it while that is still cheaper, else to a new one).
Three show at first; +N more opens the rest with their buttons, show
fewer closes them. Your teammates' tickets sit in one line (2 waiting for Berk, Carla) that opens to show them, with Discuss only, and count no
more as waiting for you. When a button fails, the error opens whole in a
pane of its own.
- A verdict with a reservation gets Explain, an open question Discuss:
each opens a Herdr tab of its own. The Explain chat writes its explanation
onto the ticket and the frame offers it as Read explanation. The Discuss
chat writes the decision onto the ticket, moves the ticket back itself when
that is the decision (it accepts only on your explicit yes), sends your
session one line, and the frame offers Read decision. Each tab closes
once its note is there.
- The frame's last line shows the context size and how long the prompt cache
stays warm,
/compact advised and a cold cache in the warning colour; a
prompt of yours onto a cold cache with a large context is held with a
choice: send, compact first, start over, cancel.
- After each of your turns a cheap model files it under a conversation
thread;
/cockpit threads shows which became a ticket, which wait for your
answer and which were left lying.
/cockpit ticket <id> opens a pane with a ticket's reports and what its
agents used (~/.jaira/metrics.csv, one row per session, in the columns of
scripts/lane-eval/metrics.sh).
- In a session that carries a ticket — the worker, lane or dispatcher a
spawn.sh tab starts — the mod only writes the session's record and
metrics: messages to it arrive as written, and it has no frame, threads or
cache guard.
Every text is in English, whatever Claude Code's language setting. In a project without a board, or without jaira on PATH, the
board parts stay out of sight and the rest works as usual — so the mod is
useful without jaira too. Install it on its own, without jaira, by typing at a
Claude Code prompt:
/plugin install jaira-cockpit --marketplace BeMuCa/jaira
Answer y to add the marketplace, then pick a scope.
Installed this way the mod runs beside whatever jaira is on PATH, and it
needs jaira 0.3.7 or later: Give back moves a ticket out of a person's lane
without --force, which older builds refuse. With an older jaira the cockpit
says so once, with both versions, when it finds the board.
Working with an agent
The whole integration surface is: run a command, read the JSON, branch on the
exit code. Nothing is specific to one tool: Claude Code, Codex, Aider, a local
model behind Ollama, or a shell script all drive it the same way.
jaira next --json # what should I work on?
jaira show <id> --for-lane in-progress --json # the prompt and bounded input
jaira dod <id> 2 --doing --plan # say where you are
jaira note <id> "the exporter buffers everything in writeAll()"
jaira move <id> --to review --what ... --why ... --resolves ... --commits "$(git rev-parse HEAD)"
Starting a session, jaira resume --json returns everything left mid-flight with
the notes written against it. A session that died to a usage limit leaves
nothing behind except what was written down.
Two things an agent deliberately cannot do: leave the human review lane, and close a
ticket whose definition of done is unmet. See docs/AGENTS.md.
Reviewing finished work
A ticket in human review opens to the four questions the decision actually needs
(what was wrong, what was done, why, and whether it holds), with the implementer's
account and the reviewer's verdict kept apart, because when they disagree that is
the most useful thing on the screen:
Accepting it moves the ticket to done. It leaves the board once you push the
work, in that order: jaira logbook <id> files it under the day you finished
it, with its commits stamped, and doing that before the push would hand a
teammate a board that has forgotten the ticket while the code it describes has
not arrived. jaira archive <id> is for a ticket that is not being worked —
abandoned, duplicate, obsolete — and works from any lane. Nothing is deleted,
jaira restore puts either back, and a follow-up keeps its link to a logged
predecessor. The board keeps showing what you filed at the foot of the
terminal lane for logbook-days days (28 by default; see Keys).
jaira logbook with no argument lists what left the board in the same window
and says how many older entries it left out; --since 10d picks another window
and --since 0 lists everything.
Commands
jaira open the home screen: your boards, and what each needs
jaira board open the board here directly
jaira web the page in the browser: sort tickets into epics, read a ticket and its notes
jaira init prepare a repository
jaira update re-apply setup after upgrading
jaira self upgrade replace the jaira binary with the latest release
jaira create <title> create a ticket
jaira list list tickets
jaira show <id> show one ticket
jaira links <id> every ticket connected to this one, wherever it lives now
jaira set <id> k=v... set fields
jaira tags the tags this board uses; read it before you tag anything
jaira boards the boards of this repository and what each is for; read it before you create
jaira tag <id> <name>... add tags to a ticket
jaira milestone ls the board's milestones; read it before you create one
jaira milestone <cmd> create, add, rm, set (--after: the Milestone Roadmap), rename (the name only)
jaira epic ls the features this board is built of, done X of Y; --unassigned the rest
jaira epic <cmd> create, add, rm, show — an epic lives on its own ref, not in a file
jaira dod <id> <n> mark a checklist item --doing / --done / --todo / --superseded
jaira note <id> <text> record what you worked out, so a later session can pick it up
jaira validate check every ticket on the board for damage
jaira logbook <id> file a finished ticket under today, commits stamped
jaira logbook <milestone> file a milestone once every ticket in it is finished
jaira logbook list what left the board in the last logbook-days (--since)
jaira archive <id> take a ticket that is not being worked off the board
jaira reject <id> --why .. say why a ticket will not be done, and archive it (a person's call)
jaira restore <file> put an archived or logged ticket, or a filed milestone, back
jaira delete <id> remove a ticket's file for good (type the handle back)
jaira move <id> --to ... move lanes, applying the gates
jaira next the next actionable ticket
jaira resume work left in progress, with everything recorded about it
jaira fetch fetch the tickets travelling on their own git refs
jaira pull <id> take a ticket over and put it on your disk
jaira release <id> hand a ticket back so somebody else can take it
jaira snapshot write the board's backup branch and clear landed refs
jaira claim <id> take a 30-minute lease on a ticket
jaira lanes installed lanes
jaira checkpoint record what this session is doing
jaira sessions sessions working this tree
jaira sync-tasks mirror an agent task list into the backlog
jaira tasks emit the board as a task list
jaira resolve <id> settle the fields a merge could not resolve
jaira projects boards you have opened
jaira projects add <path> register a board (--scan searches two levels down)
jaira whoami the identity jaira acts as, and the state of this board
jaira share publish the board (--undo to make it private)
jaira roles install write the teamlead, dispatcher and worker prompts as skills, and the cockpit mod (--agent copilot for Copilot)
jaira hook print print a Claude Code Stop-hook snippet that refuses to end a session with agentic lanes unworked
jaira json get <path> print one field of the JSON on stdin, for shell scripts that cannot count on jq or python3
jaira completion <shell> print the completion script for bash, fish, powershell or zsh
jaira help <command> the full help of one command, the same as <command> --help
Full reference: docs/COMMANDS.md.
Every read command takes --json. Exit codes are a stable contract:
| Code |
Meaning |
| 0 |
success |
| 1 |
unexpected error |
| 2 |
usage error |
| 3 |
a gate refused the operation |
| 4 |
unresolved dependencies |
| 5 |
no such ticket, or an ambiguous id |
Under --json, refusals are structured on stderr with a field naming what to
supply, so an agent can fix and retry without parsing prose.
Keys
← → lane enter open ticket n new ticket
j k ↓ ↑ card / filter (key:value narrows to one field;
spaces combine, a comma means or)
g G first / last m move ticket ? help
v compact view x archive r reload
z hide empty lanes q quit
u only some people's tickets (space ticks, enter applies)
M only some milestones' tickets (space ticks, enter applies)
l every ticket linked to the selected one (l or esc closes it)
L Milestone Roadmap: bubbles filled by done/total, arrows for what comes after what
S settings: lanes, the default board, days of logbook shown
Compact view (v): the whole flow as steps with arrows, agents counted per step,
an arrow lit when work just moved. ad/←→ pick a step, enter opens it full width,
1-9 switch project, q/esc leave it as q leaves the board.
In an opened step: jk ticket, gG first/last, hl next/previous lane (stays in
this view), enter open ticket, q/esc/v back to the compact view.
In an open ticket:
e edit fields (enter newline, ctrl+s save) a accept (at a checkpoint)
E edit body and checklists in $EDITOR f follow-up from the review
y copy the full ticket id n follow-up beside this one
b open the ticket this one is blocked by m move it
X delete its file (type the handle back) tab other pane, in the split
jk next / previous ticket l every ticket linked to it
↓↑ scroll (ctrl+d/u pages) a ticket taller than the terminal
On the Milestone Roadmap (L):
←↑↓→ nearest bubble or [+] enter open a bubble; on a [+]: n new, c connect
e name, goal, predecessors c next colour
x take it off the roadmap L esc back to the board
In an open milestone: ↑↓ ticket, enter opens it, b the board filtered to it, esc back.
On the board list (p, and the launcher):
x remove a board — choose what goes, the cursor starts on No
q goes one page back, on every page, to the same place esc goes. The board
is the top page: there q goes back to the launcher, or quits jaira when it
was started without one (jaira board), and esc clears the filter. The
compact view is the board seen another way, so q and esc both leave it the
way q leaves the board. In a text field q is a letter, and esc is the way
back.
Every screen is clamped to the terminal: a line wider than the window is cut
rather than wrapped, because a wrapped line pushes everything under it down and
shoves a footer off the bottom. Screens with more to say than fits — the help,
an open ticket — scroll with ↓↑.
Writing a follow-up (n): the screen splits, the ticket it follows stays on the
left, and the new one is written on the right, so the reason for it is still
visible while you write it. It is a draft until ctrl+s writes it into the
backlog with follows pointing back; esc discards it and the board never saw
it. n again chains from the ticket just written, which slides left. While you
type, tab belongs to the editor's fields, so shift+↓↑ scrolls the ticket on
the left; once saved, tab moves between the two panes. Below 80 columns or 20
rows there is no split and the follow-up takes the screen.
What a card can say
Every state is a glyph plus a word, never colour alone, so the board reads the
same to anyone. ? lists them with the same styling the cards use.
|
|
○ spec |
not specified enough to leave the backlog |
■ blocked |
waiting on a ticket that is not done |
▲ asks |
a question is waiting for a person to answer |
◆ sign off |
finished work waiting for a person to accept it |
◇ unworked |
its lane has not produced what it declares yet |
✎ name |
somebody else wrote this ticket last |
Plan 2/5 DoD 2/5 |
checklist progress; [-] superseded counts as settled |
✓ 3 |
commits recorded on the ticket |
sonnet |
the model that last ran a lane on it |
@name |
who owns the outcome |
⎙ filed 4 Oct |
in the logbook, filed that day — see below |
The terminal lane ends with what went into the logbook in the last 28 days,
newest first, below the tickets still waiting to be filed and filled in teal
rather than grey. Those cards are read-only: enter opens one, and m, x, R,
e, E and X answer with the jaira restore line that brings it back. How
many days is "logbook-days" in ~/.jaira/settings.json, editable on the
settings screen (S); 0 takes them off the board. The same number is the
window jaira logbook lists without --since.
✎ comes from updated-by, written on every change. It marks somebody else's
change, never your own, and "you" means any name you go by — the alias list, not
one string.
A move the gates refuse says why and offers f to override, then asks once more
before it writes. That is the same override the CLI spells --force: it is
reported in the output, not recorded on the ticket, and it covers any refusal
rather than only ownership.
What this deliberately is not
paca, Jira, and Linear already exist. jaira is smaller on purpose, and the
following are excluded with reasons rather than left as future work:
| Not built |
Why |
| Sprints, custom fields, roles, saved views, dashboards |
Full-PM-tool features. The project fails by growing. |
| Server, accounts, authentication |
Git is the sync layer. Repository access already is the permission model. |
| Web UI, VS Code extension |
Sharing is solved by git. Splitting across UI surfaces is the bloat trap. |
| Branch per ticket |
Too heavy for small tasks; breaks down under parallel agents. |
| Board-spawned background agents |
Orchestration stays in your session. Process lifecycle is a large surface for little gain. |
| Multi-type dependency graphs |
A flat blocked-by covers the actual need. |
| A CRDT ticket engine |
Solves conflicts completely, but abandons hand-editable plain files: a worse trade than tolerating rare prose conflicts. |
| Jira / YouTrack sync |
Deferred. The external: block reserves room so no migration is needed. |
Worth knowing honestly: file-based issue trackers have a poor track record.
ticgit, Bugs Everywhere and others are dead or niche, and Fossil explicitly
rejected mutable working-tree ticket files for exactly the merge-conflict reason
above. jaira's bet is that a field-aware merge driver plus a deliberately small
surface makes the plain-file approach work where those attempts did not. That bet
is not yet proven by adoption.
The recordings
The question chain under the roles is a live recording of three real Claude
sessions and is not re-shot by a script. The other three GIFs are recorded
from scripts in scripts/, not captured by hand, so they can be re-shot when
the behaviour changes instead of quietly
describing an older version:
CGO_ENABLED=0 go build -o /tmp/jaira-static ./cmd/jaira
vhs scripts/demo-split.tape # the handover
vhs scripts/demo-machines.tape # two machines, one remote
vhs scripts/demo-board.tape # the board updating itself
Needs vhs, ttyd, ffmpeg, tmux and docker. Use vhs v0.11.0: v0.12.0
runs the tape, prints "Creating …", exits 0 and writes no file at all
(charmbracelet/vhs#787).
Both sides of every recording are containers with hostnames of their own, so
"another machine" is something you can check on screen with hostname rather
than a label. scripts/demo-machines-start.sh builds that: a bare repository
plus two clones, each with its own branches, sharing nothing else. It runs tmux
on a socket of its own — a recording must never be able to reach the panes
somebody is working in.
To see the same behaviour without recording anything, scripts/try-everything.sh
walks seventeen checks through a sandbox and prints what to expect at each one.
Development
go test ./...
go build ./cmd/jaira
Windows is a shipped platform — .goreleaser.yaml builds a binary for it — but
the windows-latest CI job takes eight minutes and only tells you afterwards.
Two commands run before you push catch what has actually broken there, from
whatever machine you are on:
GOOS=windows GOARCH=amd64 go vet ./...
GOOS=windows GOARCH=amd64 go build ./cmd/jaira
go vet covers the test files too, which is where most of the Windows failures
have been. What breaks at run time rather than at compile time is caught by
internal/wintrap, which go test ./... already runs: it reads the source for
the five patterns this repository has broken on before — t.Setenv("HOME") with
no USERPROFILE beside it, a //go:embed target with no eol=lf line in
.gitattributes, a permission claim with no runtime.GOOS branch, an expected
binary name with no .exe, and a path glued together with a literal /. Each
finding names the remedy, not only the site. A site that only looks like one of
them — a URL, a git refname, an io/fs path — is marked //wintrap:ok with the
reason, so the exemption stays visible in the diff instead of the rule quietly
getting weaker.
Nothing lands on master directly. A change is made on a branch of its own,
the ticket rides in the same commits as the code, and the branch arrives through
a pull request — which is what makes the diff readable: the change and what it
was for in one place. It rides with the code and never alone: a lane that
changed no code — critique, testing, review — commits nothing at all, and leaves
the ticket file for the next commit that carries code — or, when none follows,
for the commit that files it away with jaira logbook — so the branch shows the
work rather than one entry per lane that passed over it.
The pull request belongs to the maintainer from the moment it exists. An agent
pushes its branch and stops there: it does not open the pull request, does not
merge one, and does not approve one. Opening it is the maintainer's call, given
as an instruction — so whoever wrote the change is never whoever decides it
arrives.
Layering is enforced by the module graph: core/ imports nothing from cmd/ or
internal/. The CLI and the TUI are peers over the same core, which is the only
reason "both interfaces enforce the same rules" is true rather than aspirational.