Skip to content
Conduit

mcp server

yaiba

刃 — vim-flavoured todo & gantt planner. One local binary, embedded UI, peer-to-peer sync over iroh.

yukimemi/yaibacommunitylistedunreviewed

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. o opens a row and you type; space completes it; dd deletes and u brings 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 ^umove
o Onew task below / above — you type immediately, it saves on commit
i I a A ccedit the cell under the cursor — on the title, at the head / tail / cleared
spacecomplete (the row gets cut) · s cycles todo → doing → done
xclear the cell under the cursor · dl is vim's spelling of it
dd yy Ydelete, yank rows
p Pput the last yank · P is rows only · u / ^r undo, redo
J Kmove the row down / up, changing level to suit where it lands
+ -duration ±1 day · . , start ±1 day · gp gP priority · ( ) progress
Dadd a dependency: pick the task this one waits for, ⏎
Xcut a dependency
v Vselect cells / whole rows — every edit above applies to the block
/ n Nsearch
tabsplit → list → gantt · [ ] zoom the timeline
gdplan-vs-actual date columns ⇄ compact
cs ce ca cAcalendar on the planned start / end, actual start / end
cohand the row over — the panel lists the names already in use
gtoffice mode ⇄ neon · :theme and :lang say it by name
gssuper mode ⇄ neon — every effect at maximum · :super
gwglass mode ⇄ neon — slow light, soft edges · :glass
gcthe colours — presets, and every slot by hand · :colors
h lout / 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 zrfold one level shallower / deeper · zM zR all the way
zafold 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 rowput the cursor on it
right-click a row or its barthe menu of what nothing else here reaches
⇧ right-clickthe browser's own menu, untouched
click the + on ita new task below, at the same level — what o does
click [ ]complete / reopen
click ▾fold a summary
double-clickedit the title
click the owner cellpick who it belongs to (gd shows the column)
drag a rowreorder — a line shows the slot, indented to the level it lands at
drag a barmove its start date
drag its right edgechange the duration
drag the dot past its end onto another barmake that task wait for this one
click a date cellpick that date off a calendar (gd shows the columns)
◀ ▶ beside the datemove the reference date a day
click the datejump to one, or back to now
drag the dividergive 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 endthe plan · dim means the scheduler placed it, not you
began endedthe 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 start appears — 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.tomlthe index
<data dir>/yaiba/yaiba.dbthe default project
<data dir>/yaiba/projects/<name>.dbprojects 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 / ^pmove
enterswitch
^rrename the row under the cursor
^dforget it — asks first
type a name nothing matchesoffers 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 (or gL) 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 view leaves the ticket out, for someone who already has the project. (:link and :share were 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 :url does, when you ask.
  • The fold list is the only part that grows: past 2000 characters it is left out of the link (and :url says 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 :ticket to 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:

  1. New project. Any name.
  2. Enable the Calendar API — APIs & Services → Library → Google Calendar API → Enable.
  3. Configure the consent screen — External. App name, your email for support and for developer contact. Add the scope https://www.googleapis.com/auth/calendar.
  4. 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.
  5. 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 yaibaTask stamp, 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.

cratewhat it holds
yaiba-coremodel, CRDT store, dependency scheduling
yaiba-synciroh 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.

↑↓ navigate openesc close