mcp server
yaiba
刃 — vim-flavoured todo & gantt planner. One local binary, embedded UI, peer-to-peer sync over iroh.
Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 26, 2026刃 — the blade. A vim-flavoured todo and gantt planner that runs as a single local binary and syncs directly between people. No server to host, no account to make.
Status: released — modal UI, a foldable work breakdown, dependency
scheduling with a critical path, and peer-to-peer replication over
iroh. cargo install yaiba.
Why
Task tools make you choose. Terminal tools are fast to type into but can't draw a timeline. Web planners draw beautiful gantt charts and then ask you to reach for the mouse — and to put your plans on someone else's server.
yaiba refuses the choice:
- The keyboard is the interface. Modal editing,
:commands, operators that wait for a motion.oopens a row and you type;spacecompletes it;dddeletes andubrings it back. - Dependencies are real, not decoration. Finish-to-start edges feed a forward/backward pass that computes each task's earliest start, its slack, and the critical path — so "when does this actually land" has an answer rather than a guess.
- One outline, every altitude. Tasks nest, and folding to a level changes who the view is for: level 0 is the project list a manager scans, level 4 is the work an implementer is actually doing. Same data, same keys.
- One binary. The UI is compiled in. Download, run, work.
- Peer-to-peer, not client-server. Everyone runs their own replica. Edits merge; nobody hosts.
Install
From crates.io, or build from source:
cargo install yaiba
yaiba
Prebuilt binaries for Linux / macOS / Windows are on the releases page. To build it yourself:
git clone https://github.com/yukimemi/yaiba
cd yaiba
cargo make release-build # builds the web bundle, then the binary
./target/release/yaiba
To put your build on PATH instead of running it out of target/:
cargo make install # or: makers install
Both go through the web bundle first. Plain cargo install --path .
does not, and the UI is baked in by rust-embed at compile time — a
missing bundle installs an empty one silently rather than failing.
The browser opens on http://localhost:8188. The database lives in your
platform data directory (--db to move it).
yaiba --port 9000 # somewhere else
yaiba --no-open # don't launch a browser
yaiba --no-sync # fully local, no peer endpoint at all
yaiba --relay-only # sync without binding a UDP socket — no firewall prompt
yaiba --host 0.0.0.0 # expose the UI on your LAN (no auth — trusted networks only)
Staying current
yaiba updates itself. On launch it quietly checks GitHub for a newer
release and, if there is one, downloads and swaps its own binary in the
background — the running process keeps the old one, so an update never
pulls the floor out from under an open UI. The new version applies next
time you start it.
yaiba --update notify # tell me, don't touch the binary
yaiba --update off # never look
YAIBA_NO_AUTOUPDATE=1 yaiba # same, from the environment
yaiba self-update # do it now, interactively
yaiba self-update --check # just tell me whether one exists
Keys
? shows the full list in the app. The shape of it:
j k gg G ^d ^u | move |
o O | new task below / above — you type immediately, it saves on commit |
i I a A cc | edit the cell under the cursor — on the title, at the head / tail / cleared |
space | complete (the row gets cut) · s cycles todo → doing → done |
x | clear the cell under the cursor · dl is vim's spelling of it |
dd yy Y | delete, yank rows |
p P | put the last yank · P is rows only · u / ^r undo, redo |
J K | move the row down / up, changing level to suit where it lands |
+ - | duration ±1 day · . , start ±1 day · gp gP priority · ( ) progress |
D | add a dependency: pick the task this one waits for, ⏎ |
X | cut a dependency |
v V | select cells / whole rows — every edit above applies to the block |
/ n N | search |
tab | split → list → gantt · [ ] zoom the timeline |
gd | plan-vs-actual date columns ⇄ compact |
cs ce ca cA | calendar on the planned start / end, actual start / end |
co | hand the row over — the panel lists the names already in use |
gt | office mode ⇄ neon · :theme and :lang say it by name |
gs | super mode ⇄ neon — every effect at maximum · :super |
gw | glass mode ⇄ neon — slow light, soft edges · :glass |
gc | the colours — presets, and every slot by hand · :colors |
h l | out / in — a fold, or a cell once gd is up · ⏎ i I a A cc edit it |
>> << | nest under the row above / move back out |
zm zr | fold one level shallower / deeper · zM zR all the way |
za | fold this row · zf focus its subtree, zF to come back |
Four modes, and a mouse
yaiba ships a second theme for the times a neon HUD is the wrong
thing to have open — a meeting room, a shared screen, a status deck.
Office mode drops the glow, the scanlines and the completion sweep
entirely, and swaps cyan/magenta for the blue/red/amber a reader already
knows from every other planning tool. It prints.
gt office mode <-> neon mode
:theme light or by name
And a third for the opposite occasion. Super mode is the same HUD with nothing held back: the glow multiplier goes up, an aurora drifts behind the plan, a CRT band rolls down the screen, the wordmark flickers like a sign, the cursor row catches the light, the critical path marches, and every stroke the blade draws is answered by the whole screen — a shockwave on a completion, a shake on a delete.
Typing draws too. Every character throws short strikes off the caret and
puts a small recoil through the shell, a run of them keeps a count, and
the count makes both louder — the power-mode gag with a blade in place
of the sparks. It follows the caret through anything you can type into:
a task title, the : line, search, the project palette. Japanese draws
kana by kana as the reading is typed, and the 変換 that commits it lands
heavier than a single key.
gs super mode <-> neon mode
:super bare toggles · :super on / :super off
:theme super or by name
It is one axis, not a switch beside the theme: gt takes you out of
super the same way it takes you out of neon, because office mode is
somewhere you go to and it wins. Two things survive being turned up
this far — the palette rule, so magenta still means the critical path
and nothing else, and prefers-reduced-motion, which turns the whole
mode's motion off and leaves nothing frozen on screen.
And a fourth, opposite the third. Glass mode is the same HUD with the brakes left on and the clock turned down instead: the panels frost — a blur behind them rather than a colour under them — light drifts behind the plan the way it does through water, the cursor row breathes rather than sweeps, the critical path still marches but at a slow tide's pace, and a stroke still answers at the scale of the whole screen, as a soft ripple rather than a shockwave.
Two things it deliberately does not have. No shake on a delete and no strikes off the caret while typing — both are fast gestures, and fast is the one thing this theme refuses to be.
gw glass mode <-> neon mode
:glass bare toggles · :glass on / :glass off
:theme glass or by name
It rides the same axis super does: gt takes you out of glass exactly
the way it takes you out of super, and the two loud themes cannot be on
at once — pressing one always turns the other off first, on the way to
wherever it is going.
The choice is remembered, and a fresh install follows your OS preference.
The colours are yours
The four modes above decide how loud the screen is. What each colour
is is a separate question, and gc opens the panel that answers it:
twelve named slots, a hex field and a swatch on each, and a handful of
presets to start from.
gc the colour panel · :colors, or ◍ in the bar
ネオン / オフィス which of the two grounds you are editing — the tab
switches the theme with it, since editing colours you
cannot see is not editing
yaiba the built-in palette: the way back from any preset
gruvbox nord dracula tokyo on the neon ground
solarized · gruvbox light on the office one
What you are replacing is the value of a slot, never what it means.
Cyan is the blade, magenta is the critical path and amber is overdue —
that rule is the reason a row can say something in a colour instead of a
word, so the panel lets you pick which magenta and not which meaning.
Two things are deliberately out of reach for the same reason: the glow,
which is the whole of office mode and lives on gs / gw, and anything that is
not a colour.
The two grounds keep separate palettes, because a fill mixed for a
near-black background washes out to nothing over white. × puts one slot
back; the yaiba preset puts the ground back. It is remembered next to
the theme, so it survives a reload and follows you across projects.
The UI has a language too, and that one starts in English:
:lang ja 日本語で表示します
:lang en back to English (bare :lang toggles)
It is the whole surface, not a veneer: the ? panel, the status line's
replies, every refusal the : line can give you, the column heads and
the project palette. English is the default rather than your browser's
locale, because a screenshot of yaiba should read the same wherever it
is opened; ja is a choice, and it is remembered. Both toggles sit at
the right of the top bar for a mouse.
What is not translated is anything you type. Key names, command names,
todo / doing / done, tag names and the words a command accepts
(day, week, month) read the same in either language — a help that
renamed the keys would be describing a different program from the one
under your fingers.
The keyboard remains the point, but every common action has a mouse equivalent, because handing the screen to someone else should not require handing over the keybindings too:
| click a row | put the cursor on it |
| right-click a row or its bar | the menu of what nothing else here reaches |
⇧ right-click | the browser's own menu, untouched |
click the + on it | a new task below, at the same level — what o does |
click [ ] | complete / reopen |
click ▾ | fold a summary |
| double-click | edit the title |
| click the owner cell | pick who it belongs to (gd shows the column) |
| drag a row | reorder — a line shows the slot, indented to the level it lands at |
| drag a bar | move its start date |
| drag its right edge | change the duration |
| drag the dot past its end onto another bar | make that task wait for this one |
| click a date cell | pick that date off a calendar (gd shows the columns) |
◀ ▶ beside the date | move the reference date a day |
| click the date | jump to one, or back to now |
| drag the divider | give the list more of the width, or less |
That table used to have a hole in it, and the hole was the shape of a
rule this project keeps in the other direction: nothing should be
reachable only by clicking. Deleting a row was reachable only by
typing. So were its priority, its progress, its nesting, its note — and
doing, which is the one a status meeting is actually about, since
[ ] only ever toggles the two ends of todo → doing → done.
Right-click fills it. What is on the menu is decided by a rule rather than by taste — an item is there exactly when the mouse cannot already reach it, and every item names the key it runs:
⚑ mark it doing s
◆ priority ▲ gp ▼ gP
% progress + ) − (
✎ note… :note
⇥ nest / unnest → >> ← <<
⌖ focus this subtree zf
✂ yank the row yy
⎘ put it below p
↶ undo u
✖ delete the row dd
Both halves of the rule are load-bearing. The first keeps the menu from
becoming fifteen items nobody reads, and shrinks it on its own: add a
direct gesture for something and its entry leaves, which
check-rowmenu.ts asserts against the list of gestures rather than
leaving to memory. The second makes the menu teach — you right-click to
delete a row and the menu tells you it was dd all along, so the mouse
path advertises the keyboard one instead of competing with it.
It is also the one panel here with no keyboard opener, and for the same
reason: co had to have one because it lists the names already in use,
which is something no key can tell you, and this lists keys, which ?
already does. Nothing is behind a click that is not also behind a
keystroke — that is the point, not an exception to it.
Deleting from the menu asks nothing first. dd does not either, u
brings the row back — drawing it back, since an undo now replays the
stroke its own ops describe: a restored task is born again, and a task
an undone o takes away is cut down first — and right-click → move →
click is already two acts of intent; the status line says what went.
⇧ and right-click declines the event and you get the browser's menu,
because there is no API that opens it — declining is the only offer a
page can make. Only rows and bars take the button at all, so anywhere
else it was never ours.
The divider between the list and the timeline is draggable — the line you
see is the thing you grab, with a few pixels either side so it is
catchable. Double-click puts it back to the default. The width is
remembered like the theme is, so it survives a reload, and :split 40
sets it by number for anyone who would rather not aim. Grabbing it also
leaves it holding the keyboard, so ← and → — or h and l — move it
2% at a time, and Home puts it back. That the mouse is what hands it the
keyboard is not an oversight: tab is spoken for by the layout cycle, so
there is no keying your way over to the divider without touching it first.
Neither side can be dragged below 15%: a pane squeezed to nothing reads as
a bug, and there is no grip left to pull it back by. tab is how you
actually hide one.
Dragging a bar pins its start: a task placed by its dependencies gets an explicit date, which is what makes the gesture survive the next recompute. Summary bars cannot be dragged — their dates are a consequence of what is inside them.
The + shows on the cursor row only. On every row it is a column of plus
signs and the eye stops reading the tasks; and the row it sits on is the
one whose level the new task inherits, so the indent it will land at is
the indent you are looking at. An empty project has no row to hover, so
there the prompt itself — o to open a new task — is the button.
The owner panel is the mouse's way to a field the command line reached
first, and it lists the names already in use rather than offering a bare
box. That list is the only thing keeping a team to one spelling, since
there is no roster behind the names: tab does that job on the : line,
and clicking has no tab. co opens the same panel from the keyboard —
nothing in yaiba should be reachable only by clicking.
And a phone
The same app, reached over the network and laid out for a thumb. Nothing is installed on the phone and nothing syncs to it: it is a browser looking at the yaiba already running on your machine, so there is one replica and one store, and what you type on the train is on your desk before you are.
yaiba --host 0.0.0.0 # or YAIBA_HOST=0.0.0.0
There is no authentication, and this does not add any. Everything at
that address can read and write the plan, so the network has to be the
thing you trust. A mesh VPN is the cheap way to get that and to keep
working away from home — with Tailscale, yaiba --host 0.0.0.0 plus the tailnet address is the whole recipe:
tailscale ip -4 # 100.x.y.z
# then http://100.x.y.z:8188 from the phone, with Tailscale on there too
On a LAN with no VPN it works the same way and stops at the front door. Do not put it on the open internet: put a reverse proxy that authenticates in front of it, or do not do it.
Windows: the prompt you already said no to
Binding the address is not the same as being reachable. yaiba's sync
endpoint raises a firewall dialog on startup — the one wanting an
administrator — and answering it with anything but allow writes
block rules for that executable, inbound TCP and UDP, on the
private and public profiles. --host 0.0.0.0 then binds perfectly and
the phone still gets nothing.
Block beats allow in Windows Firewall, so the old rules have to go before a new one means anything. In an administrator PowerShell:
# What is there now — Action: Block on your yaiba is the thing to remove.
Get-NetFirewallRule -Direction Inbound |
Where-Object DisplayName -like '*yaiba*' |
Select-Object DisplayName, Action, Enabled
Get-NetFirewallRule -Direction Inbound |
Where-Object { $_.DisplayName -like '*yaiba*' -and $_.Action -eq 'Block' } |
Remove-NetFirewallRule
# Then let the tailnet in, and only the tailnet: 100.64.0.0/10 is the
# CGNAT range every Tailscale address lives in, so this opens nothing on
# the LAN or a cafe's wifi.
New-NetFirewallRule -DisplayName 'yaiba UI (tailnet)' -Direction Inbound `
-Action Allow -Protocol TCP -LocalPort 8188 `
-RemoteAddress 100.64.0.0/10
--relay-only is worth pairing with this if the dialog is what you were
avoiding: it syncs through relays and binds no UDP socket at all, so
nothing asks again.
What changes on a small screen
Below 720px the layout is one pane at a time — at 390px the split's list
would be 180px, which is a column of ellipses — and tab, on the bar or
the keyboard, swaps between the list and the chart. The stored :split
is left alone, so the desktop is unchanged when you come back to it.
Everything a key does is still done by a key. What the phone gets is a route to the keys it cannot type:
- The bar along the bottom is eight global commands — new task,
list/chart, zoom out, zoom in, dates, the
:line, undo, today. Each button is its key: it hands the same string to the same command layer the keyboard uses, so nothing on it can drift from what the keyboard does. It stands down while you are typing, because the keys it would run are not the keys that mode is listening for. - A long press on a row opens the row menu — the same menu the right button opens, which is where everything that acts on one row lives: status, priority, progress, a note, nesting, focus, yank, put, undo, delete.
- The handle at the left of a row drags it to a new place. A press that moves anywhere else is a scroll, which is why the handle exists at all.
- Bars, edges and dependency arrows keep their drags, with hit areas
a finger can actually land on. The date columns (
gd) scroll sideways inside the pane rather than pushing the app off the screen.
What a phone does not get is the keyboard's speed, and nothing here tries to fake it: this is for looking at the plan, ticking things off, and fixing a date — the work you do standing up.
Projects, and the level you look at them from
There is no separate "project" object: a task with no parent is a project. That means the same fold commands that collapse a sub-task collapse a whole project, and one gantt spans all of them.
zM every project on one screen, each showing its own roll-up
zr open one level — the phases inside each project
zf zoom into just this subtree
zF back out to the folds you had before it
:level 2 jump straight to an altitude
A task with children becomes a summary: its bar spans its children's dates and its percentage is their duration-weighted roll-up, so a 9-day task at 100% and a 1-day task at 0% reads 90%, not 50%. You never type a summary's dates — they are a consequence of the work inside it.
Dependencies and nesting are separate axes on purpose: a parent contains its children, a dependency orders two tasks. Only leaves are scheduled from dependencies; summaries follow.
You can still link a summary, and it means what it looks like: an edge onto one holds back everything inside it, and an edge out of one waits for the whole bracket to close — the last child, not the first. Under the hood the edge is rewritten to run between the leaves, since those are the rows that carry dates. The one pairing that is refused is a summary and something inside it, which would ask a task to finish before itself.
An edge carries how long after its predecessor the successor may start,
and :dep ⟨row⟩ +⟨days⟩ is where you say it:
:dep 3 row 3 finishes, this one starts the next day — the default
:dep 3 +0 row 3 finishes, this one may start the same day
:dep 3 +5 five days after, for parts arriving or paint drying
+0 is the one worth knowing about. Two half-day jobs done in one
sitting are a real shape, and until an edge could say so the second one
was always pushed to tomorrow. Re-running :dep on an edge that
already exists is how its spacing changes; there is no need to unlink
first.
You rarely have to, though. A pinned start is a floor to the scheduler,
and a pin dropped inside an edge's lag — by dragging the bar onto its
predecessor's day, by :start, from the calendar, by a pasted cell, or
with . / , — adjusts the lag to the spacing the date implies, in
the same commit, and says so on the status line. A pin before the
predecessor's finish would invert the edge, and is refused with the
date it does finish.
The default stays at one day, so no existing plan moves. A negative lag is refused rather than clamped quietly: it would mean the two overlap, which "A finishes before B starts" cannot carry — that would want a different kind of edge, not a smaller number.
blocked still means "a predecessor is not done", not "not today". A
same-day successor is drawn alongside its predecessor and is still
blocked, because the edge is about order rather than dates — a task
waiting on unfinished work should not stop saying so just because the
scheduler placed it conveniently.
Commands take dates the way you'd say them: :due tom, :due mon,
:due +3d, :due 8/14. Filters compose: :f tag:dev open crit, and
:f late is the one that answers "what has actually slipped" — every
row whose finish has gone by with the box still unchecked, due date or
no due date.
tab completes on the : line, wildmenu and all: the command name
first, then that command's own vocabulary — :sort its keys, :tag the
tags already in use, :assign the people already on something, :due
the words above. shift-tab walks back, and either one past the end of
the list returns what you had typed.
Who owns what
One person per task. In the working view it rides the row as @name,
next to the tags; switch to the date columns with gd and it becomes an
owner column instead — fixed width, so a roster reads straight down the
page. The chip keeps "whose is this" answerable without a mode change;
the column is what a progress meeting scans. Never both at once, which
would be the same name twice on one row.
:assign yuki hand it over — the visual block, if one is selected
:assign and bare takes it back (`none` / `-` read the same)
:f @yuki just theirs
:f unassigned the rows nobody has picked up
:sort owner one person's work in a block, unowned last
There is no user table behind the name and there deliberately isn't
one: inventing a roster would mean every replica agreeing on it before
anybody could be assigned anything. So the people who exist are exactly
the people already on something, which is what tab completes from —
and completing rather than retyping is the only thing keeping Yuki and
yuki from becoming two names in a report nobody notices are one
person. Matching ignores case regardless; the spelling you typed is the
one that is stored, because with no registry it is the only record of
how somebody writes their own name.
Unlike tags, the owner is a single last-writer-wins field: two peers naming different people converge on the later write rather than both sticking, and every replica picks the same one. The loser's name is gone, not merged — which is the point. A task never ends up with two owners, and it only ends up with none if clearing it is what won.
A name is one word, the way a tag is: the filter grammar is
space-separated, so mary jane would be two terms and match neither.
:assign says so rather than storing something you cannot then find.
Plan vs actual, and the progress line
Every task carries both sides. The plan is start + duration; the
actuals are stamped as work happens — actual_start the first time a
task leaves todo, actual_end when it is done. Reopening a task
clears its finish, because leaving it would quietly corrupt every
comparison downstream.
:start mon # the plan: where it begins…
:dur 5 # …and how long it takes
:end 8/20 # or say where it lands, and the duration follows
:astart 8/18 # the actuals, when you are recording after the fact
:aend 8/22 # `none` on either one clears it
The two sides are shaped differently on purpose. A plan is start +
duration because the scheduler moves it: when a dependency slips,
what stays true is how long the work takes, not the day it happens to
finish. So there is no stored end date — :end is sugar that measures
back from the date you name and writes the duration. An actual span is
the opposite: both ends are recorded, because nothing recomputes what
already happened, and the days between them are not "how long it took"
anyway.
The gantt draws each recorded span as a thin rail under the bar, so the offset between plan and actual reads without a legend — flush left started on time, a rail past the bar's right edge ran long. Work still in progress fades out at the reference date rather than claiming a finish it doesn't have.
When the four dates are the point — a progress meeting, a status sheet someone else fills in — the list will show them as columns instead of its markers:
gd date columns <-> compact
:dates the same toggle by name · :cols compact
start end | the plan · dim means the scheduler placed it, not you |
began ended | the record, quieter, behind a rule |
A task you have not pinned starts today — or at the reference date,
when :asof has moved it — and then as late as its predecessors force.
The floor is a default, not a clamp: :start on a past date is a
statement and is kept. It applies to a task with predecessors too, so a
chain whose earlier links finished last month does not lay the rest of
itself out behind you. What actually happened belongs in began /
ended; the plan is for work still ahead.
Upgrading to 0.12 moves rows, once. Before it, an unpinned task sat at the oldest date anywhere in the plan, so on a project with history every one of them will jump forward on first load. Nothing is written and no
startappears — they were never pinned and still are not; the scheduler has simply stopped answering "when can this be done" with "whenever the project began".
Click a cell and a calendar opens over it — hjkl or the arrows walk
the grid, [ ] page months, t jumps to the reference date, x
clears, ⏎ commits. The keyboard opens the same panel on the cursor
row, without reaching for the mouse or turning the columns on:
cs calendar on the planned start · ce on the end
ca calendar on the actual start · cA on the actual end
c is change, the way cc changes the title. Every pick runs the
command you could have typed, so it obeys the same rules: picking an
end writes a duration, an actual span is refused if it would run
backwards, and a summary's plan is not editable at all — those cells are
plain text rather than buttons, and cs on one says so.
Those five keys each name one field, which is what you want when you are somewhere else in the plan and just need to fix a date. Filling a column in is the other job, and for that the cursor walks:
h l out / in — the cell to the left, the cell to the right
j k the next row, still in this column
⏎ edit the cell you are standing in
h and l mean one step out and one step in, and a cell is a smaller
step than a subtree — so at the leftmost cell, where there is no cell
further out, they are the fold's again. That is the whole rule. In the
compact view there is one column, so they only ever fold, which is what
they did before the columns existed.
Standing still in a column while j walks down the page is the point:
⏎, pick, j, ⏎, pick — a roster or a set of planned starts, filled
straight down without the mouse and without retyping a command per row.
Or copy what is already there. v selects a rectangle of cells rather
than a run of rows, so the shape you take is the shape you put down:
v j j y one column, three rows
v l j y two columns, two rows
p put it down with the cursor on its top-left corner
It lands by offset, not by column name — that is what makes yanking
start end and dropping it on began ended the two keystrokes it
should be, which is the comparison the columns exist for. A column whose
kind disagrees does not land: a date will not overwrite a title, and the
status line says which cells it skipped rather than half-writing the
block and looking like it wrote all of it. Running off the last column
or the last row reads the same way.
Every cell is written by running the command you could have typed, so
the rules do not move: end is a duration measured back from the start,
so pasting one onto a row that starts elsewhere changes its length; an
actual span that would finish before it began is refused; a summary's
plan cells are refused. And a start the scheduler placed for you looks
the same as one you pinned — copying it pins it on the row it lands on.
V takes whole rows instead, which is the selection v used to be.
Rows and cells are two registers and two kinds of put: yy / Y yank
rows and p makes copies of them, y yanks cells and p writes them
over what is there. p puts down whichever you filled last.
P is the exception: it only ever puts rows. A row block makes room
above the cursor, which is what "before" means for a row; a cell block
overwrites the cells you point it at, and there is no above to land on.
The gantt draws a progress line (イナズマ線) from the reference date: each row steps left or right by how far it deviates from where the plan says it should be. Straight means on schedule, a notch left is behind, a bulge right is ahead. You read the project's health from the shape, not from a column of numbers.
:asof 2026-07-20 # the plan as it stood that day
:asof -3d # three days ago
:asof today # back to now
The reference date sits in the top bar at all times — it is the number
every bar, percentage and overdue flag is measured from, so leaving it
unstated made the view depend on something it never showed. ◀ and ▶
walk it a day at a time, which is how you watch the progress line bend;
clicking the date opens a jump list and a date field. It reads as grey
chrome while you are at now and turns amber the moment you are not,
because moving off now is a mode, and one that refuses edits.
Forward is as available as back: a date ahead of now answers "if nothing moves, how far behind is this by Friday". What a past date will not show you is a task that did not exist yet — the snapshot drops anything created after the date, so a task entered today is absent from yesterday's view even when its planned dates run back a fortnight. The filter is on when the task was written down, not on where its bar sits.
Progress and status are recorded per day, so a past reference date shows what was known then rather than today's numbers back-projected. The recording is one entry per task per day: several edits on one day collapse to that day's final value, and a day you didn't touch a task costs nothing. A year of active use is a few megabytes.
Fields that keep no history — titles, dates, the breakdown — are shown as they are now. That is a real limitation rather than a papered-over one: the CRDT keeps only the latest value for those, and inventing a past for them would be worse than admitting there isn't one.
Working days, and days off
A duration is counted in something. By default that something is
calendar days: :dur 5 is five squares on the chart, weekend included.
Switch the project over and the same 5 means five days somebody is
actually at work:
:cal what this project counts in, and what it counts as off
:cal on count durations in working days · :cal off back to calendar days
:cal week mon-fri which weekdays are worked · mon-sat · or a mask: 1111100
It is off until you ask. Turning it on moves every finish date later, which is not something an upgrade is allowed to do to a plan you already trust — so a project that has never heard of this schedules exactly as it did before.
Once it is on, everything that counts days counts the same way: a
dependency's lag of one day means the next working day, so a task
finishing on Friday hands over on Monday, and a slack of 3d is three
days somebody could have worked rather than three squares two of which
are a weekend. A pinned start stays a floor rather than becoming a
promise: pin a task to a Sunday and it begins on the Monday, while the
pin you typed is left exactly where you typed it.
Days off
Any date can be named, and any date can be taken back:
:cal holiday 12/29 年末休み # a day the office is closed (the name is optional)
:cal workday 8/15 # a Saturday everybody is in
:cal clear 12/29 # no opinion either way — back to the week mask
That is the whole mechanism, and it is deliberately not tied to any country: mark the dates you don't work, from wherever you get them. A calendar usually arrives as a file rather than as something you type, so the same verbs exist on the command line and take one:
yaiba cal # what this project counts in
yaiba cal on # ...and switch it over
yaiba cal week mon-sat
yaiba cal region jp
yaiba cal holiday 2026-12-29 年末休み # one day
yaiba cal holiday --file days.csv # or a year of them, in one request
yaiba cal workday --file open.csv # the days you are in anyway
yaiba cal clear --file days.csv # and the way back out
The file is one date[,name] per line — # starts a comment, a tab works
as well as a comma, and a name may contain commas because the split is on
the first separator only. --file - reads stdin, so anything that can
produce a list of dates can feed it:
curl -s https://example.test/holidays.csv | yaiba cal holiday --file -
A line that names no date is dropped, and a date named twice keeps the last name it was given — the format is forgiving on purpose. It is not quiet about it, though, because a file that lost two lines must not print what a clean one prints:
3 days marked off. (2 lines skipped, 1 date repeated)
One request, so it either all lands or none of it does. The server validates every line before writing any of them; a typo on line 40 leaves the calendar exactly as it was and says which date it choked on. Fifty separate requests would leave it halfway.
Like yaiba mcp and yaiba gcal, this talks to a yaiba that is already
running rather than starting one of its own — the server stays the only
writer.
What ships built in is one table, because the dates it holds move around by law every year and typing them in annually would be miserable:
:cal region jp # 国民の祝日, with 振替休日 and 国民の休日 derived
:cal region none # nothing built in (the default)
It is a region, not a Japan switch — adding another one is a variant in an enum and a function beside it, and nothing stored or on the wire changes. Two bounds worth knowing, both of them stated in the code as well: the equinox dates are the standard approximation and hold for 1980–2099, and because the table lives in the binary rather than in the data, two replicas on different versions can resolve the same project to different dates until both are upgraded.
The chart shades every non-working day, holidays included, and names them
in the header where they have a name. :cal on its own only reports —
it never writes, so an early ⏎ cannot re-plan the project.
The calendar belongs to the project and syncs with your peers, in the same CRDT log as the tasks. It has to: the schedule is computed by each replica from the same tasks and dependencies, so a calendar that stayed local would have two people looking at the same plan and reading different dates off it.
Working with peers
Each replica prints a ticket at startup, and :ticket copies it from
inside the app. Hand it to someone:
# them
yaiba join <ticket>
That's the whole setup. From then on both sides sync automatically — immediately on every edit, and on a timer to catch up after being offline.
Their tasks arrive as a project of their own — a separate database,
listed under a name you can pick with --as — so joining someone never
mixes their backlog into yours. See Projects.
No port forwarding, no inbound firewall rule. iroh dials by public key and hole-punches, so outbound UDP is all that's needed. If hole punching fails the connection falls back to a relay that forwards already-encrypted QUIC and cannot read what passes through. Peers on the same network connect directly and never touch it.
Hole punching does bind a UDP socket on every interface, and Windows
greets that with a firewall prompt — which on a locked-down machine asks
for an administrator who isn't you, and comes back on every start.
--relay-only binds no UDP socket at all:
yaiba --relay-only # sync over the relay, no UDP socket of our own
YAIBA_RELAY_ONLY=1 yaiba # same, permanently, from the environment
The UI's listener is unaffected — it is on loopback, which no firewall asks about, and that is true in either mode. Everything still syncs and the ticket is unchanged: a peer dials your public key and never learns which path answered. What you give up is the direct connection, so it is slower and it needs the relay to be reachable.
The ticket carries a 32-byte room key. A peer that can't present it is dropped before any data moves.
Projects
A yaiba database is one task set, one sync room, one identity — nothing
in the replication layer scopes tasks to a project inside a database. So
a project is a database file, and yaiba keeps an index of them.
yaiba # the default project
yaiba new private # start one of your own, and open it
yaiba list # what's registered, most recent first
yaiba open work # open one by name
yaiba open # fuzzy-pick one
yaiba join <ticket> --as work # take a peer's tasks as a new project
yaiba merge <ticket> # fuse two replicas into one plan (no undo)
yaiba forget work # drop the name; the database stays on disk
Keeping apart what should be apart is what new is for — a backlog you
share with someone, and one you don't:
yaiba new shared # hand this one's ticket out
yaiba new private # never share this one, and nobody can reach it
Nothing leaves a project until you hand out its ticket. Each has its own database, its own sync room and its own identity, so sharing one says nothing about the others.
Whatever you open is registered as you go, --db paths included, so the
picker fills itself in without any setup step. Your default database is
adopted the moment it exists, so yaiba list and the picker already know
about it before you have opened anything. The index is a TOML file meant
to survive being hand-edited:
| path | |
|---|---|
<data dir>/yaiba/projects.toml | the index |
<data dir>/yaiba/yaiba.db | the default project |
<data dir>/yaiba/projects/<name>.db | projects you joined |
YAIBA_DATA_DIR moves that whole root — one variable for a
self-contained yaiba on a synced folder or a stick.
Losing the index costs names and ordering, never tasks: every database
still opens with yaiba --db <path>, registered or not.
One yaiba holds them all. Starting it opens every registered project, not only the one you asked for, and each one replicates on its own — so a project is up to date when you turn to it, rather than starting to catch up at that moment. There is no second process and no second port.
Manage them from inside the app, without restarting. :proj opens a
fuzzy picker over what is open — and so does clicking the project name in
the top bar, which is the way in for a mouse.
| in the picker | |
|---|---|
^n / ^p | move |
| enter | switch |
^r | rename the row under the cursor |
^d | forget it — asks first |
| type a name nothing matches | offers to create it |
rename and forget are buttons on that row too. From the command line:
:proj <name> switches, and :proj new <name>, :proj rename <name>
(renames the one you are on) and :proj forget <name> do the rest, with
<tab> completing names.
Forget only drops it from the list — the database stays where it is, which is what the picker says before it does anything.
A registry entry whose database has gone is skipped with a warning rather
than stopping the launch. If opening everything costs more than it is
worth — a long registry, a slow disk, a metered link — --only-active
opens just the one project.
join keeps them apart, merge mixes them together
Two things you can do with somebody's ticket, and they are opposites:
yaiba join <ticket> --as work # their tasks, as a project of your own
yaiba merge <ticket> # both task sets, in both replicas
join is the one you almost always want. The peer's tasks land in a
database of their own, so nothing you already have is changed and nothing
of yours is shared with them. :join <ticket> in the UI does the same,
without a restart.
merge fuses two replicas into one plan, and it cannot be undone.
Both task sets end up on both sides, and this project leaves its own sync
room for theirs — the old room key is overwritten, so anyone holding your
previous ticket is dropped on their next sync. It merges whichever
project would open by default; --project <name> picks another.
:merge <ticket> in the UI does the same.
Both were spelled join until v0.21 — the --join flag merged, the
join subcommand did not — and since only one of the two can be taken
back, guessing wrong cost people the separation they had set up. The flag
now refuses and names both replacements.
Sharing a view as a link
The address bar is always a link to what is on screen — view, zoom,
columns, sort, filter, folds, focus, the :asof date, the cursor row and
the show-hidden toggle (#view=split&sort=due&fold=…). Theme, colours,
language and the split width are yours and are not in it. It is the URL
fragment, so it never reaches the server; opening one overrides your
saved view for that session without rewriting it, until you change
something yourself. Task ids are the same on every replica, so ids the
recipient does not have are ignored.
:url(orgL) copies the full URL with this project's ticket, so the recipient lands on the project — switched to if they already have it (matched by the ticket's room key, not by name), or offered as a join behind a confirmation.:url viewleaves the ticket out, for someone who already has the project. (:linkand:sharewere already taken, by dependencies and by:ticket.):open <link>(alias:goto) takes a pasted URL or a bare#…fragment and applies only the fragment to the page you are on — for a recipient on another host or port, since a copied URL carries the sender's origin.- A link with a ticket is the ticket. Whoever holds it can read and
write that project. The fragment is not sent to the server, but it does
land in browser history and wherever the link is pasted. The address bar
itself never contains the ticket; only
:urldoes, when you ask. - The fold list is the only part that grows: past 2000 characters it is
left out of the link (and
:urlsays so) — roughly fifty folded rows.
leave is the way back out
yaiba leave [--project <name>] # forget its peers, move to a room of your own
:leave in the UI does the same, and asks first.
It works after either verb. The project forgets its peers and moves to a new sync room: it stops dialling them, and anyone still holding the old ticket can reach the endpoint but is turned away, because the room no longer matches. The tasks stay exactly as they are — including the ones that arrived from the group, which are yours to keep or to delete now that you are out. Delete them after leaving; before, the deletions replicate.
Two things it does not do:
- It cannot take back what already reached them. Their replica is as complete as yours, and nothing here reaches across to remove it. Leaving stops the future, not the past.
- It changes this project's ticket, because a ticket is the endpoint
and the room together. So every replica holding the old one is cut off
— your own other machines included. Share the new
:ticketto pair up again.
Letting an agent read and edit the plan
yaiba mcp serves the plan over MCP,
so a coding agent can answer "what's blocking the release?" and break a
task down without you retyping any of it. It talks to a yaiba that is
already running, so leave one up and register the server once:
yaiba # in one window, and leave it
claude mcp add yaiba -- yaiba mcp # once
Point it elsewhere with yaiba mcp --url http://127.0.0.1:9000, or
YAIBA_MCP_URL. http:// is what you want — this dials your own
machine, which has no certificate to present.
Eight tools: plan reads everything (computed dates, critical path,
what's blocked, what's overdue); add_task, update_task,
delete_task, link and unlink edit it; projects and
switch_project move between plans. Tasks are named by title or by the
short id plan prints — an ambiguous name is an error listing the
candidates rather than a guess.
The agent is a client like any other. It goes through the same HTTP API the UI does, so every rule holds without being restated: a dependency that would close a loop is refused, a summary's dates still come from its children, and a pinned start is still a floor. Every write answers with where the plan then stands, which is usually the point — cutting one dependency can move the finish date a week.
There is no authentication on the API, and the MCP server is a local
process talking to 127.0.0.1. Keep it that way.
Putting the plan on a Google Calendar
yaiba gcal push writes the schedule to a calendar of its own, so the
week you planned sits beside the meetings you actually have — on your
phone, in whatever your team already looks at.
It runs when you ask it to. Most tasks have no pinned start, so their dates come out of the dependency graph and one new edge moves many bars at once; pushing continuously would be a calendar rewriting itself while you think. On demand it is one reconcile: work out the difference, apply it, stop. Running it twice does nothing the second time.
Ask for it either way: :gcal push on the : line, or yaiba gcal push
in a terminal. Both are one POST /api/gcal/push to the server holding
the plan, so they do the same reconcile and report the same counts —
:gcal push puts them in the status line, and when Google refused an
event it names the refusal there rather than counting it as done.
Setting it up
yaiba has no Google account of its own, so you lend it one. Once, about five minutes, in the Cloud console:
- New project. Any name.
- Enable the Calendar API — APIs & Services → Library → Google Calendar API → Enable.
- Configure the consent screen — External. App name, your email
for support and for developer contact. Add the scope
https://www.googleapis.com/auth/calendar. - Publish it. On the consent screen, set the publishing status to In production. Do not skip this — a project left in Testing is issued refresh tokens that expire after seven days, and you will be logging in again every week without being told why.
- Create credentials — Credentials → Create credentials → OAuth client ID → Desktop app. Keep the client id and secret.
Then tell yaiba, and log in:
export YAIBA_GCAL_CLIENT_ID=....apps.googleusercontent.com
export YAIBA_GCAL_CLIENT_SECRET=GOCSPX-...
yaiba gcal login # opens your browser; grant access. Once per machine.
yaiba # in one window, and leave it
yaiba gcal push
Google will say the app is unverified. It is — it is a client you made for yourself five minutes ago, and nobody reviews those. Advanced → Go to … (unsafe).
The URL is printed as well as opened, so this works over SSH or on a
machine with no browser; --no-open skips the launch and leaves only the
print.
Both processes read those variables: yaiba gcal login in the
command you type, and yaiba gcal push in the server that holds your
plan. So set them somewhere both inherit — a shell profile rather than
the one window you happened to be in.
login needs no running yaiba — the credential is yours, not a
project's, so it writes straight to your credentials file. push does
need one: it is reading a plan. That asymmetry is why the : line has
:gcal push and no :gcal login: the consent screen wants a browser
and a listener on this machine, which is the terminal's job rather
than the server's. Typing :gcal login says so instead of failing.
What lands on the calendar
- A calendar of its own, called
yaiba: <project>. Nothing is written to your primary one, and getting the plan back off your calendar is deleting that calendar rather than trusting yaiba to tidy up after itself. - All-day events, one per task, spanning the dates the gantt draws.
- Leaves only. A summary's span is the union of its children's, so an event for it would book the same days twice.
- Events yaiba did not create are never touched. Every event it
writes carries a private
yaibaTaskstamp, and anything without one is somebody else's — including anything you add to that calendar yourself. - Removed tasks take their events with them. A task deleted, or one whose event you dragged somewhere it does not belong, is put back the way the plan says on the next push.
- Renaming the project renames the calendar on the next push — but only while the calendar still carries the name yaiba gave it. Rename it yourself on the Google side and it keeps the name you chose.
yaiba gcal push acts on the active project — the one :proj is
looking at — and each project gets its own calendar. You log in once per
machine, not once per project: the credential is yours, so it lives in
credentials.toml beside the project registry in your data directory,
while the calendar each project writes to is the project's own business
and stays with it.
Nothing is read back. Google's events carry no dependencies and no breakdown, and a start dragged in a calendar would fight the forward pass that computed it.
How it works
Edits merge instead of colliding
The data model is a CRDT — a last-writer-wins map keyed by
(task, field), stamped with a hybrid logical
clock. Granularity at
the field level is what makes concurrent editing feel unremarkable:
if you set a due date while someone else raises the priority of the same
task, both survive. Tags are one entry per tag rather than one array, so
+dev and +ui added at the same moment both stick — where the
assignee is a single entry precisely so two people naming different
owners don't both stick. Deletes leave a
tombstone, so a peer that hasn't heard about them can't resurrect the
task on its next sync.
The HLC's node id breaks ties, which means two replicas that receive the same pair of concurrent writes in opposite orders still land on the same answer.
Sync is a version-vector exchange: each side says what it has seen, and only the difference crosses the wire.
The schedule is computed, never stored
Bar positions come from a forward pass over the dependency DAG (earliest start, honouring any pinned start date) and a backward pass (latest start, hence slack). Zero slack means critical path — drawn in magenta. Blocked, overdue, late and level are derived the same way, so they can't drift from the graph they describe.
The last two answer different questions and a plan usually has more of
the second. Overdue is the plan overrunning a date somebody typed:
it needs a due date, and it compares two planned dates. Late is
the ordinary case — the computed finish has gone by and the box is
still unchecked — so it needs no due date at all, it is measured
against the reference date and moves with :asof, and a summary is
late whenever anything inside it is, which is what keeps a folded
branch from hiding it. Both draw amber, on the row's title and on its
bar; :f late filters to them and the HUD counts them beside crit.
Cycles are rejected when you create them, but the renderer still tolerates one: two peers can independently add edges that only close a loop once merged, so a cyclic graph is a state the UI has to survive rather than an error it can refuse.
Development
cargo make check # fmt + clippy + test + lock-check — the pre-push gate
cargo make dev # server against a scratch db
cargo make web-dev # Vite dev server, /api proxied to the above
cargo make web-build # rebuild the embedded bundle
cargo make smoke # release smoke: sqlite, embedded UI, iroh endpoint
The SPA lives under crates/yaiba-server/web/ — inside the crate,
because rust-embed bakes web/dist/ into the binary at compile time.
A release built without cargo make web-build still compiles and still
starts; it just serves an empty shell. cargo make smoke exists to
catch exactly that.
| crate | what it holds |
|---|---|
yaiba-core | model, CRDT store, dependency scheduling |
yaiba-sync | iroh transport and the sync protocol |
yaiba (in crates/yaiba-server/) | HTTP API, embedded SPA, CLI |
The binary crate is named yaiba so that cargo install yaiba gives you
yaiba; its directory keeps the -server suffix to say what it holds
next to -core and -sync.
License
MIT — see LICENSE.