| .claude/skills | ||
| .forgejo/workflows | ||
| cmd | ||
| deploy | ||
| docs | ||
| internal | ||
| third_party/verta-identity | ||
| .gitignore | ||
| CONTEXT.md | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
| vertaship.yaml | ||
wave
A task manager built around the one thing TickTick, Todoist and Things cannot express: a project decomposed into ordered waves.
area → project → wave → task
A wave is a phase. It stays blocked until every earlier wave is finished, so a plan carries its own sequencing instead of living in your head. Areas are PARA areas, the Inbox is anything not yet filed, and every object is reachable from an AI agent over MCP.
Why it exists
TickTick has no project object — "projects" are parent tasks, so there is no project status, no project dates, no progress, and no way to sequence work. Linear has waves (milestones) but no PARA areas and no recurring tasks. This is the intersection.
Install
go install github.com/qusavin/wave/cmd/wave@latest
Or from a checkout:
make install
make build installs too, because a stale copy on PATH loses features silently rather
than failing. wave version prints the commit and build time — check it first whenever a
key seems to do nothing.
The TUI reads and writes the replica of the space you chose — one SQLite file per space at
~/.wave/spaces/<space>/replica.db — so wave login and wave use come first. It never
waits for the network: reads are local, and a change is applied and queued in one
transaction, then sent in the background. The legacy single-machine file at
~/.wave/wave.db is still what wave serve opens (-db overrides that one), and
wave import moves it into a space.
The three faces
| Command | What it is |
|---|---|
wave |
vim-keyed TUI — the desktop interface |
wave serve |
HTTP API on 127.0.0.1:7717 — for scripting and shortcuts |
wave mcp |
MCP server on stdio — the agent interface |
TUI
wave
j / k down / up a o i add task
J / K move the row e rename task, project, wave or area
gg / G top / bottom n / N notes in $EDITOR / one line here
1 2 3 4 5 today · inbox · t due date — on a project or a wave, its target
projects · areas m move to area / project / wave
· calendar s sheet — due, move, priority, span, repeat
tab next view x done / undone
enter / l open dd delete
esc / h back / widen u take back the last act; again goes further back
r reload p new project
? help w new wave (inside a project)
ctrl+d half a page down A new area
ctrl+u half a page up E area icon (empty clears it)
ctrl+s account panel q quit
S today: by area, by project, the order you put it in
O sort by due date — written down, so every device gets it
f / F narrow the list to the project · the area of the task under the cursor
/ narrow by text
c show / hide completed
# / @ today: hide the row's project · area
z reveal what is hidden
! everything in conflict
space / V select the row under the cursor · everything the scope is showing
u let the whole selection go — with none standing, step back through the acts
On a machine nobody has signed in on, wave still opens: the chrome is drawn, the body is
empty because there is no replica to read, and the account panel is the screen rather than
something over it. enter there starts the device flow in the box — a code and an address
to approve it at, a wait that shows it is waiting, and then either the operator's data or
the list of spaces to choose it from. esc stops waiting; q is the way out.
Quick add parses the line:
buy milk @household → filed into an area
fix parser #mimic ^tomorrow → into a project, due tomorrow
ship mvp #wave ^2026-09-01 !3 → due date and priority
standup ^tomorrow ~09:00-09:30 → an hour of the day it is due
Due dates accept today, tomorrow, mon…sun, +3d, +2w, +1m, 2026-09-01. Spans
accept 13-15, 13:00-14:30 and 90m, and hang on the day the line is due — with no day
to hang on, the line is refused rather than filed at no particular hour.
Neither has to be written out. Type the sigil and the row under the prompt becomes the
field you are in: a week of days under ^, the hours of the day under ~. H and L walk
a day or carry the span; J and K step a month or move the span's end. What the walk lands
on is written back into the line, so enter takes the same thing whichever hand put it there
— and a date the parser cannot read is refused on the line it was typed on, with the rest of
the line still standing. The sheet's due and span fields are the same two strips.
m retargets a task with the same syntax — @english, #golang /prep, or inbox to
send it back.
J and K reorder, in every view including Today. Tasks carry one global rank, and a
move swaps the task with the row actually shown next to it — so a Today row steps past
its neighbour even when the two live in different projects. Inside a project the swap
stops at the wave boundary, because a wave header breaks the run.
Ordering is manual, not automatic. O re-sorts whatever list you are looking at by due date
when you want the dates back in charge — it writes the ranks down, so every device gets that
order. S is the other half and the opposite kind of thing: it reads Today grouped by area or
by project on this terminal, writes nothing and travels nowhere, and while one is live every
key that would write a rank refuses rather than folding a view order into the stored one.
5 is the week. Seven day columns and hour rows, read out of the replica like any other
view, with a cursor that walks cells rather than tasks — an empty cell is where a task would go,
so it is reachable. z narrows the week to the day under the cursor and back, r halves the
rows, t returns to today, and enter opens the same sheet the list opens. Two gestures write:
s takes an edge of the block under the cursor (tab swaps ends) and m picks the task up —
both stage a change that enter writes and esc throws away. A repeating task is drawn on every
day its rule lands on, and only the one row is stored.
f narrows the list to the project of the task under the cursor, F to its area, /
to a phrase — one narrowing said three ways, so esc takes all of it off. The header
names the scope beside the tabs and the bottom line counts it — scope: 3 tasks · #verta-rail — so the size of what the next key means is on screen before you press it. A
task with no project refuses f in words rather than narrowing to an empty list, and an
Inbox task refuses F the same way. The scope is this terminal's own: nothing is synced,
and it lasts until you widen it or change view.
space selects a task, V selects the whole scope. Space takes the row under the
cursor and walks on, so space-space-space picks a run; V takes everything the current
narrowing is showing, and pressing it again takes that same scope back out — scoping to
the wrong project would otherwise cost a u and every mark made before it. u lets the
whole selection go.
u takes back the last act. With no selection standing, u is undo: it takes back the
last thing this terminal did, whole. Fifty tasks deleted by one dd come back with one
press, because the act is the unit and never a row of it — and the footer names what came
back, 12 tasks restored. It appends rather than rewinds: the inverses are ordinary
operations minted from the values each op overwrote, so they reach every other device like
anything else, the log is never rewritten, and the whole act lands or none of it does. An
archive and a deleted area, project or wave have no other side in the vocabulary and refuse
out loud; dd no longer asks first, because the reason it asked is what u just removed.
Press it again and it goes further back, not forward. An undo is itself an act, so it records the id of the act it took back, and what the next press takes is the newest act that is neither an undo nor already undone. That is the whole stack: it is the log, so it lives in the replica and the walk is where you left it after a restart, and an undo is never itself undone. Redo is deliberately not a feature — walking forward again needs a branching model, and nothing asks for one. It reaches back through the last hundred acts, undos counted among them, so about fifty presses; the end of it is a quiet line saying which end — everything already back, or the bound. One thing is stepped over rather than refused: an act the log kept without the values to put it back, from before preimages existed or captured against a row already gone. The footer counts those, so a press that went further than it looks says so.
Narrowing and selecting are two different things to have said, so esc widens the list
and leaves the selection standing: f on a rail row, V, esc, then space on two
strays elsewhere is one batch of five gathered out of two lists. A selection is task ids,
not row numbers — it acts on every id in it, including the ones the list you act from is
not showing, in the order the screen has them with the rest after. The bottom line carries
both facts side by side: scope: 3 tasks · #verta-rail · selected: 5.
Completing a task does not make it vanish: it stays where it is, ☑ and struck through,
until you leave the view or press r. x again reopens it.
c shows completed tasks in whatever view you are in — they render ☑ and struck
through, and the header gains +done. In Today that means everything due today plus
everything closed today, including tasks that never carried a due date, so the list
doubles as a record of the day. Over HTTP: /api/today?done=1, /api/inbox?done=1, or
/api/tasks?status=done. Over MCP: include_done on wave_today and wave_inbox.
Tab completes the @area, #project or /wave token under the cursor, in every
prompt. The candidate strip sits above the input; shift-tab takes the last match instead
of the first. Wave completion is scoped to whichever #project the line names, or to the
project you have open.
Descriptions are markdown. n opens the description in $EDITOR as a real .md
file — nvim here, with your own config and ftplugin — and saves it back on exit. N is
a quick one-line edit that never leaves the TUI. enter on a task opens it full-page
with the markdown rendered; j/k scroll it. A task carrying a description shows ≡,
and its first line previews under the list.
ctrl+s opens the account panel over whatever you are looking at: your spaces, which
one you are in, and how much that one still has to send. enter switches — the list, the
replica and the bottom line all follow in the same frame, with no restart — s signs out
and lands you back on the way in, and ctrl+s or esc folds it away again. It is a panel and not a page: the list behind it stays readable,
which matters most in the case that opens it, when the origin has stopped answering and the
work on this machine has not. On a client with no space to work in it is the whole screen,
and there it does not close, because there is nothing behind it to go back to.
The bottom line is the connection. It always says which space you are in and how the link to it is doing:
Personal · idle caught up
Personal · syncing talking to the origin
Work · offline · 3 pending no network; three changes waiting to leave
Personal · idle · conflicts: 2 two texts need you to choose
The count is real — those are intents already on disk and not yet accepted. Nothing you
type waits for the network: a change lands in the replica and in the queue in one
transaction, and the queue drains on its own once there is a network. Kill the process
mid-queue and the work is still there on restart. r re-reads the replica and, if the
origin refused something, asks it to try again.
Two machines end up in the same place. Work on the laptop with no network and on the
desktop with one, and when the laptop comes back neither of you is asked to merge
anything: reorders in different lists both stand, reorders in the same list settle into
one order that is the same on both screens, a repeating task ticked off on both for the
same occurrence moves on once, and a task deleted on one is gone on both. The one case that
cannot merge — the same note edited on both while apart — keeps both versions instead of
picking one; see below. What makes this true rather than hoped for is
internal/syncer/converge_test.go, which runs two whole installations against one origin in
a single test.
Phone
Open https://wave.vertaspace.com, tap Sign in, approve in the browser and pick a space —
the choice is remembered, and the tab in the header is where you switch space, open a new
one or sign out. Add it to the home screen: it installs as a standalone PWA with its own
icon.
The phone does what the terminal does, in a thumb's vocabulary. Today, the Inbox, projects
and areas; a capture bar that files into whatever screen it is standing on; a search field that
narrows the list live. Tapping a task opens one editor with the same five fields the terminal's
sheet writes — due date, where it goes, priority, the hours it takes, how it repeats — and the
repeat is picked on a strip rather than typed. Hold a row and drag it to reorder Today or the
Inbox. Areas, projects and waves are made, renamed, dated, archived and deleted from here too,
so a space can be shaped from scratch on a phone and not only added to. An undo control in the
footer takes back the last thing you did, whole, and pressing it again goes further back. The
footer's conflict count opens a screen where each disputed field is drawn as its two versions
and you keep one by tapping it. Today can be grouped by area or by project and containers hidden
from it — that choice stays on this device and travels nowhere. And the calendar draws the same
week and day the terminal draws: long-press a block to carry it to another day or hour, take an
edge to change how long it takes, with every date a repeating rule lands on shown and only the
one row stored. A write to a repeating task asks which occurrence it means before it writes,
exactly as t, m and dd ask in the terminal.
The phone is a device like the laptop, not a window onto the server. It holds a full
copy of the space — one IndexedDB document, wiped when you sign out — and every read comes
out of it, so the app works in a tunnel exactly as it works at a desk. A tap does not send
anything: it records what you did (complete_task, set_fields, move_task) into that
copy and into a queue, in one write, and a loop drains the queue and follows the log
whenever there is a network.
The footer says which of those it is:
Personal · idle caught up
Personal · syncing talking to the origin
Work · offline · 3 pending no network; three changes waiting to leave
Personal · idle · conflicts: 2 two texts need you to choose
Personal · idle · storage not persistent the browser has not pinned the copy
Edit the same task on the phone and on the laptop, both offline, and neither edit is
lost. They come back to one state with nobody merging anything — and when the one case
that cannot merge happens, the same text edited in both places, both versions are kept and
the task is marked ⚠ on every device. Settle it by editing the text and deleting the
marker line, on whichever device is in your hand.
The phone runs the same op vocabulary as the desktop — and by now most of the same reads and
judgements — written a second time in JavaScript because a browser cannot run the Go one. That
copy is not taken on trust. internal/syncer/phone_converge_test.go loads the very file the
browser is served and plays the phone through these scenarios beside a real desktop replica,
inside go test; phone_core_test.go compares its answers against the Go ones function by
function; phone_replica_test.go drives its storage and its sync loop against a stubbed
IndexedDB; and internal/httpapi/serviceworker_test.go asks the service worker which paths it
is allowed to answer for. No Node, no browser: 143 tests in one make test.
wave serve no longer serves this app. It is the hosted origin's, because a replica needs
a journal to reconcile against and the local binary has none; what wave serve offers is
/api/* for scripting and shortcuts.
Nothing about that is compiled in: the app asks the server it is talking to for its login
(GET /v1/auth/config), and the local binary answers 404, which is how one file serves
both. Sign-in is authorization code + PKCE straight against identity — the device flow the
CLI uses is for devices that cannot take a redirect, and a browser can (ADR-0007).
Captures made offline are held per space, so a draft written in one can never be sent to another, and switching back finds it still waiting. Signing out erases the tokens, the chosen space and every held draft on that device.
For capture from anywhere, point an iOS Shortcut at:
POST http://<host>:7717/api/tasks {"title": "..."}
Agent
wave mcp
Register it once:
claude mcp add wave -- wave mcp
The agent works as you, in the space you chose: it reads ~/.wave/session.json and calls
the hosted origin, so wave login and wave use <space> are what point it at anything.
With no session every tool answers with an error naming wave login — never an empty
list, which an agent would read as "nothing to do". A wave use in another terminal is
picked up by the next tool call; the server does not need restarting.
Tools: wave_today, wave_inbox, wave_add_task, wave_list_tasks,
wave_update_task, wave_complete_task, wave_reorder_task, wave_delete_task,
wave_restore_task, wave_list_areas, wave_create_area, wave_list_projects,
wave_create_project, wave_create_wave.
Areas, projects and waves resolve by name, not just id — {"project": "system design", "wave": "2"} works, so an agent never has to carry UUIDs around.
Behaviour worth knowing
Nothing you wrote is thrown away. Two devices editing the same description while both were offline is the one case that cannot merge, so wave keeps both:
the version I wrote on the train
<<<<<<< wave: two versions, keep one and delete this line
the version you wrote with the budget in it
The task shows ⚠ in every list, the bottom line counts them, and ! lists every
conflicted task wherever it lives. Settle one the way you write anything else — n opens
the text in $EDITOR (e for a conflicted title, which is no longer one line), keep what
you want, delete the marker line, save. That edit is an ordinary change, so it travels to
your other devices by itself; the conflict does not stay settled only where you settled it.
Titles conflict by the same rules. A delete racing an edit loses to the edit — the task
comes back rather than taking the writing with it. The phone is in this on equal terms: it
marks the same task, counts the same conflicts in its footer, and settling one there is the
same ordinary edit that settles it in $EDITOR.
Waves gate by availability, not by lock. A blocked wave renders dim and reads as
blocked; nothing stops you completing out of order. The gate is a signal, not a jail.
Recurring tasks roll forward. Completing a task with an rrule does not close it —
it advances to the next occurrence after the moment you completed it and carries its time
span with it. Days you missed are not owed: a daily task completed nine days late lands on
tomorrow, not on the day after the one it missed.
The rule is picked, not spelled. The sheet's repeat field opens a strip of cells rather
than a line: h/l walk it, space toggles the cell under the cursor, enter fixes the
rule, esc leaves the last fixed one standing. Weekly picks weekdays, daily an interval,
monthly a day of the month, and toggling the strip off clears the rule without touching the
due date. A rule the cells cannot say — COUNT, UNTIL, an import from another calendar —
is shown verbatim and left alone until you write over it, rather than rounded to the nearest
thing the strip can express. Full RRULE syntax still goes in over the API and the agent:
FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR.
An edit asks which occurrence it means. A repeating task is one row standing on one date —
the calendar draws a copy on every day the rule lands on, but only the row is stored — so
writing to it writes the whole chain. The rule, rather than a tally: every gesture in the
terminal that writes a field the chain inherits stops and asks first — renaming with e,
writing a note with n/N, moving with m, t, dd, the bulk sheet, the calendar drop and
the span resize, whichever door each of them is reached through. h/l pick, enter answers,
esc writes nothing.
whole series is the write it always was. this occurrence lifts that one occurrence out as an
ordinary one-off task and rolls the series on past it — under dd it is the roll alone, no copy
and no tombstone, so "delete today's" on a daily task moves it on instead of ending it. Either
way u takes the answer back whole. It reaches the occurrence the row is standing on and no
further: over a copy the calendar drew three weeks out, the box offers whole series and names
why the other half is not there. The API and the agent have no box: update_task writes the
stored series directly, by design — there is no cursor there to have meant one occurrence by.
Time spans are real. start_at + end_at on a task, surfaced as span_minutes in
the API and 09:00–10:30 in the UI.
Inbox is a query, not a list. Any task with no area and no project is in the Inbox.
Cross-container views carry a breadcrumb. Today, Inbox and an area listing show
where each task lives — 🇬🇧 English › English speaking system › wave 1 Prep — because a
row pulled out of its container is meaningless without it. A task with no home reads
inbox. Inside a project the breadcrumb collapses to just a foreign area badge.
A project can span several areas. The area lives on the task, not the project. A
task filed into a project inherits that project's area, but you can override it — so
vertaship can hold AI work and Foreign-job work at once. The project row then shows
+area for every area beyond its own, and enter on an area lists everything filed
there, project work included.
API
wave serve binds loopback; -lan binds 0.0.0.0 and prints the address, unauthenticated,
which is fine at home and not on café wifi. It runs over the legacy local file and writes rows
straight, so nothing changed through it reaches the log. The same routes are on the origin
behind identity, and there they are journalled: a request mints an op and applies it in one
transaction, exactly as a pushed op is. So a change made through them — which is everything
wave mcp does — reaches every replica on its next pull, and a change the op vocabulary
cannot express is refused with a 4xx that names it rather than written quietly.
GET /api/today GET /api/inbox
GET /api/areas POST /api/areas
GET /api/projects POST /api/projects
GET /api/projects/{id} PATCH /api/projects/{id}
GET /api/projects/{id}/waves
POST /api/projects/{id}/waves
GET /api/tasks POST /api/tasks
PATCH /api/tasks/{id} DELETE /api/tasks/{id}
POST /api/tasks/{id}/complete
POST /api/tasks/{id}/reopen
POST /api/tasks/{id}/restore (undoes the DELETE above; 404 if there is no task to give back)
POST /api/tasks/{id}/reorder {"delta": -1} (moves through the global order)
On the origin, behind identity and the X-Wave-Tenant space header:
GET /v1/tenants POST /v1/tenants
GET /v1/whoami
POST /sync/push {"device_id": "...", "ops": [{"op_id", "act_id", "kind", "payload"}]}
GET /sync/pull ?device_id=...&since=<seq>&limit=<n>
/sync/push is the journal: it answers with the seq it gave each op, in the order they
were sent, and sending the same op_id again returns the same seq without writing
anything — a dropped answer is retried, not reconciled.
act_id says which ops were one decision — twelve tasks completed by one keystroke share
one, twelve completed one at a time do not (ADR-0019). It is optional: an op recorded before
acts existed, or by a client that predates them, carries none and is an ordinary op in every
other respect. The origin stores it and hands it back on a pull; what it is for — taking an
act back — lives on the device that recorded it.
/sync/pull reads it back. With a since it answers with the ops after it, in order,
bounded by limit and saying has_more when there are more. Without one it answers with a
snapshot of the space at a known seq — materialised rows plus the ids that are dead — and
the device follows the log from there instead of replaying it from zero. The since a
client sends is also its acknowledgement: nothing else is treated as one, so a lost answer
costs nothing.
Layout
cmd/wave entrypoint, subcommand routing
internal/domain the bizmodel — Area, Project, Wave, Task
internal/storage sqlite, goose migrations, repository
internal/service gating, recurrence, progress rollup
internal/httpapi REST handlers, DTO mapping, the embedded PWA — index.html,
wave-core.js (the vocabulary in JS), wave-replica.js (IndexedDB + the loop)
internal/mcp JSON-RPC over stdio, tools, name resolution
internal/tui bubbletea model, vim keymap, completion, $EDITOR bridge, rendering
cmd/wave-server the hosted origin — the copy every device will sync against
internal/config envconfig, WAVE_ prefix
internal/logger zap
internal/origin echo server, identity middleware, spaces, /sync/push, /sync/pull
internal/postgres pgx pool, goose migrations, the op applier
internal/ops the op vocabulary — the seventeen intents, their payloads, and the pull page
internal/storage the device replica: the same rows, the unsent queue, the cursor, the applier
internal/syncer the client loop — push, pull, the chosen space, the connection state
internal/recur next occurrence of a recurrence rule, shared by service and applier
The origin
wave-server is the hosted half: one Postgres-backed server every device syncs against.
It holds the rows, the spaces they live in, and the journal that orders every change
(VERTA-3564), and it hands that journal back — by cursor, or as a snapshot to a device
starting from nothing (VERTA-3566). The TUI runs against a replica of it (VERTA-3568) and
so does the phone (VERTA-3576): each records intents, applies them locally, and reconciles
in the background. The agent has no replica and calls /api/* over the network, but the
origin journals those routes too (VERTA-3615), so what the agent changes reaches every
replica. wave serve offers the same routes over the legacy local file, unjournalled, for
scripting.
make build-server # bin/wave-server
make test-integration # testcontainers: migrations from an empty database
Run it against a local Postgres:
export WAVE_POSTGRES_DSN='postgres://wave:wave@localhost:5432/wave?sslmode=disable'
./bin/wave-server
GET /healthz 200, names the commit; never touches the database
GET /readyz 200, or 503 when Postgres stopped answering
Every setting comes from the environment, WAVE_ prefixed — WAVE_HTTP_ADDR
(:7717), WAVE_POSTGRES_DSN (required), WAVE_LOG_LEVEL, WAVE_LOG_FORMAT
(json/console). Nothing is read from a flag: a secret on a command line ends up in
shell history.
Who is asking
Every route except the two probes needs a verta identity token. identity issues opaque tokens, not JWTs, so the origin cannot check a signature: it asks identity about the token (RFC 7662 introspection) and caches the yeses for seconds. A revoked session therefore stops working almost at once instead of living out its expiry, and any failure — identity down, hung, erroring — is a refusal, never an accidental yes.
A refusal names whose fault it is. 401 means the token was the reason: absent,
malformed, inactive, minted by another issuer or for another audience. When the origin
could not reach a verdict at all it answers 503 with Retry-After, because "we could
not ask identity" is not "your session is bad", and sending the operator to sign in
again is advice that cannot work (ADR-0015). Which check failed stays in the log; the
caller learns only which side of the wire to look at.
WAVE_IDENTITY_BASE_URL where the discovery document lives
WAVE_IDENTITY_AUDIENCE wave's application_id (vt_client_ACYJ1VDBGJA963H5);
a token minted for another app is refused
WAVE_IDENTITY_INTROSPECT_TOKEN wave's own service token, scope session:introspect
WAVE_IDENTITY_CLIENT_ID the application the phone logs in as; defaults to
the audience, which is the same string today
The issuer and the introspection endpoint are read from the discovery document at startup, never hardcoded. Both required variables have no default: an origin that starts with authentication quietly off is not a state worth being able to reach.
GET /v1/whoami 200 with the account behind the token and the space it resolved
to, 401 without a token
GET /v1/tenants the account's spaces; sits ahead of the space resolver, because
listing them is what you do when you cannot yet name one
POST /v1/tenants opens another space, `{"name": "Work"}`
The contract itself lives in third_party/verta-identity/pkg/infraauthv1 — vendored,
not forked. Re-copy it from verta-identity rather than editing it.
The origin also serves the phone app itself — /, its two scripts, its service worker, its
manifest, its icons and /auth/callback — all without a token, because a signed-out
operator has to be able to load the page they are going to sign in on. The list is explicit
rather than a /* prefix: authentication is global with an allowlist precisely so that a
route added later is closed until somebody opens it, and an asset that is not on the list
answers 401 to the operator who has not signed in yet. GET /v1/auth/config publishes where
that sign-in happens, read from the discovery document rather than written down. Preflight
requests are exempt by method, not by path: an OPTIONS carries no Authorization by
definition, so demanding one answers every cross-origin caller with a 401 it cannot act on.
Registering the browser redirect is an Admin action on the Wave application: add
https://wave.vertaspace.com/auth/callback and leave allowed_origins empty — identity
derives the origin from the redirect and admits it to the CORS lane the login needs
(ADR-0007).
Spaces
One account can keep several spaces — work and personal, invisible from inside each other. The terminal picks one and remembers it.
In the TUI that is ctrl+s: the account panel lists the spaces, marks the one you are in,
switches to another with enter and opens a new one with n — a name typed inline, and
the space appears in the list with the cursor on it. Making one is not switching to it, so
the replica under the interface stays where it was until enter says otherwise. A name the
account already uses is refused before the request goes out: the origin would take it, and
the pair would then be two spaces wave use cannot tell apart. Signing in happens in the
same box — an account owning exactly one space lands straight in it, because the origin
resolves that case itself — and so does signing out: s drops the tokens and the choice
together and leaves you on the screen you sign in from, not in the shell. Unsent work is
named as a number and asked about first; it stays in that space's file and goes up at the
next sign-in either way. The subcommands are the same thing without a screen, which is
what wave mcp, wave serve and scripts have:
wave login # device flow, opens a code to approve elsewhere
wave tenants # the account's spaces, `*` marks the current one
wave tenants --create Work # opens another
wave use Work # by name or by id; the choice survives a restart
wave whoami # who you are, and which space you are in
wave logout # wipes the tokens and the choice together
The choice is stored in ~/.wave/session.json (0600) beside the tokens and travels on
every remote call as X-Wave-Tenant. Own more than one space and choose none, and the
origin refuses rather than guessing — the CLI turns that into wave use. Per-space
client state — the replica, the unsent queue, the cursor and this device's id — is one
SQLite file under ~/.wave/spaces/<space>/replica.db, so switching cannot send one
space's queue to another: the other file is simply not open (ADR-0006). Either way in — the
panel or wave use in another terminal — the previous space's unsent work stays in the
previous space's file; from the panel the switch is immediate, and from another terminal a
running TUI picks it up within a few seconds.
In production it ships to wave.vertaspace.com through Vertaship — deploy/ holds the
compose and the runtime image, vertaship.yaml declares the shape, and the deploy
workflow builds, pushes and rolls it forward. Secret values live in Infisical and reach
the node as deploy/.env; .claude/skills/{run-local,deploy,secrets} are the local
runbooks.
Importing the local database
The single-machine file predates the origin, so it moves there once — as ops on
/sync/push, never as a snapshot written past the journal:
wave import --dry-run # what would travel, and where; sends nothing
wave import # asks which space, takes a backup, then pushes
wave import -space Work ~/old.db
The source is opened mode=ro and never migrated: a file still on the pre-00004 schema
is read as it is, and the manual order is minted from position, so an un-migrated file and
a migrated one produce byte-identical ops. Every op_id is a hash of what the op says, so a
second run is a replay the origin already knows rather than a second set of rows. A copy of
the database — with its -wal and -shm, which is where a WAL tail lives — is taken into
~/.wave/backups/ before the first op goes out, and the path is printed.
Not done yet
The agent works only online — its writes reach every replica (VERTA-3615) but it holds no local
copy of its own, so with no network it does nothing. A recurring task ticked off on the phone
rolls forward on the spot for the rules wave's own picker writes, and only those: a rule carrying
COUNT or UNTIL, or one imported from another tool, still waits for the origin to say which
date it lands on next, because a client that guessed would put the task on a day no other device
has (ADR-0027). Subtasks under a task, notifications, an archive view.