The events log
View as markdownEvery state change Shelbi observes is appended to one file:
~/.shelbi/events.log(or $SHELBI_HOME/events.log if you've set it). It's the live wire
the orchestrator listens on to decide what to do next, and the audit
trail you grep when something looks wrong.
The log is hub-global, not per-project: lines for all projects running against this hub interleave in the same file. Filtering by project (or workspace, or issue) is the orchestrator's job. At the filesystem layer, it's one stream.
Defined in crates/shelbi-state/src/event_log.rs (events_log_path,
append_workspace_event, append_task_event).
Line shape
The prefix after the timestamp tells you which kind of event the line carries:
Workspace transitions
<rfc3339-timestamp> project=<name> workspace=<name> <prev-state> -> <new-state>2026-06-22T14:22:11+00:00 project=myapp workspace=alpha none -> working
2026-06-22T14:23:47+00:00 project=myapp workspace=alpha working -> awaiting_input
2026-06-22T14:23:48+00:00 project=myapp workspace=alpha awaiting_input -> working- The leading
project=<name>scope is load-bearing:events.logis hub-global and workspace names are only unique within a project, so each orchestrator filters toproject=<its-own-name>. <prev-state>isnoneon the first observation of that workspace.- States:
working,awaiting_input,blocked,paused. See workspace states. - The
workspace=token names the workspace whose state changed. - Written by the hub-side poller every time it observes a state change (not on every tick, only on actual transitions).
Task transitions
<rfc3339-timestamp> project=<name> task=<id> workflow=<name> <from-column> -> <to-column> reason=<short> from_category=<cat> to_category=<cat>2026-06-22T14:24:03+00:00 project=myapp task=fix-login workflow=task backlog -> todo reason=user:cli from_category=backlog to_category=ready
2026-06-22T14:24:11+00:00 project=myapp task=fix-login workflow=task todo -> in_progress reason=orchestrator:auto-dispatch_workspace=alpha from_category=ready to_category=active
2026-06-22T14:31:52+00:00 project=myapp task=fix-login workflow=task in_progress -> review reason=workspace:ready-marker from_category=active to_category=handoff- The leading
project=<name>scope is load-bearing, exactly as for workspace lines. workflow=<name>is the workflow the issue runs under (from its frontmatter); an empty value is treated as the default workflow.<from-column>and<to-column>are the snake_case status names from the issue's workflow (default workflow:backlog,todo,in_progress,review,done). See workflows.from_category=<cat>andto_category=<cat>are the status categories of those columns, derived from the workflow so reaction rules can match semantically without re-reading the workflow YAML.reason=<short>is a single token (whitespace is folded to underscores) describing who triggered the move.
Heartbeats
<rfc3339-timestamp> project=<name> heartbeat zen_eligible=<N> idle_workspaces=<M>2026-06-22T14:25:11+00:00 project=myapp heartbeat zen_eligible=5 idle_workspaces=4- The line always carries
zen_eligible=(how many issues Zen could act on) andidle_workspaces=. When Zen Mode is On azen=onmarker is inserted right afterheartbeat, and a trailing reminder may follow. - Emitted by the hub-side poller on the cadence set by the
heartbeatkey inproject.yaml. Set to"off"to disable. - Adaptive cadence: the poller holds at the standard
interval(default60s) while there's supervisable work in flight, then backs off exponentially once the board is quiescent, doubling each idle tick and capping atmax(default5m/300s). A fully idle hub relaxes60s → 2m → 4m → 5m(capped), so even a long-quiet board still sweeps for a silently-stuck issue about every five minutes. Backing off on "no supervisable work" rather than "no log line" is deliberate: a stuckin_progressissue also emits nothing, and the heartbeat sweep is exactly what catches it. - Resets on any real event: the poller compares
events.log's mtime against a baseline it advances on each seed/emit/reset, so any genuine line (an issue move, a workspace transition, a dispatch) both skips that tick's emission (the event already woke the orchestrator) and snaps the cadence back to the standardinterval. An active board never sees padding lines. - Crash-safe: a poller restart waits one full interval before its first attempt, rather than firing the slot it missed.
- Paused while offline: each due tick TCP-probes
1.1.1.1:443with a one-second timeout, and skips emission if the probe fails. Heartbeats resume on the first interval after connectivity is restored. The motivation is that the orchestrator can't act on a heartbeat during a network drop, so the line would only fill the feed with noise.
Heartbeats exist for the orchestrator's events tail --follow watch.
When nothing's happening on the board, the watch sits idle for as long
as it takes for something to happen, which can be hours, or never if
a marker-emitting code path silently regresses. The recurring line is
the orchestrator's fallback trigger to wake up and check active issues.
The TUI activity feed filters heartbeats out by design. They'd
produce one "nothing happened" row every few minutes. Use
shelbi events tail if you want to see them.
The feed lives behind the Activity view in the dashboard: the same events this log records, rendered for a human instead of grepped:
The orchestrator distinguishes the line kinds by which marker tokens the
body contains (EventKind::from_body matches on contains, not a fixed
position). Because every state line now leads with the project= scope,
workspace= and task= are the third token; other kinds are keyed off
dispatch, mode=zen, zen-dryrun, ci, message=, pane_alive=,
and more. New line shapes get added over time; consumers that only
recognize task= and workspace= should fall through gracefully on the
rest.
Atomicity
Lines are appended via O_APPEND in a single write_all of the full
formatted line including the trailing newline. POSIX guarantees that
appends ≤ PIPE_BUF (4096 bytes) under O_APPEND are atomic relative
to other appenders, so concurrent writes from the CLI and the poller
interleave whole lines rather than tearing.
This matters because both the CLI (when you run shelbi issue move) and
the poller (when it observes state changes) write to the same file
concurrently. The test
concurrent_task_and_workspace_appends_dont_tear in
crates/shelbi-state/src/event_log.rs locks this property in.
Tailing the log
The CLI gives you a tail -f-shaped view:
shelbi events tail # last 20 lines, exit
shelbi events tail -n 100 # last 100 lines, exit
shelbi events tail --follow # last 20 lines, then stream
shelbi events tail --since 10m # everything in the last 10 minutes
shelbi events tail --since 2h --follow--since accepts <n>s|m|h|d (e.g. 30s, 5m, 2h, 1d); a bare
integer is seconds. When --since is set, -n is ignored and every
matching line is printed.
The follow loop polls the file every 250ms, holding back the final
fragment until its newline arrives so you never see a half-written event.
If the file is truncated or rotated underneath it (len < offset), it
restarts from the top rather than silently dropping the next writer's
content.
2026-06-22T14:24:03+00:00 project=myapp task=fix-login workflow=task backlog -> todo reason=user:cli from_category=backlog to_category=ready
2026-06-22T14:24:11+00:00 project=myapp task=fix-login workflow=task todo -> in_progress reason=orchestrator:auto-dispatch_workspace=alpha from_category=ready to_category=active
2026-06-22T14:24:11+00:00 project=myapp workspace=alpha awaiting_input -> working
2026-06-22T14:31:50+00:00 project=myapp workspace=alpha working -> awaiting_input
2026-06-22T14:31:52+00:00 project=myapp task=fix-login workflow=task in_progress -> review reason=workspace:ready-marker from_category=active to_category=handoffThat five-line burst is what one issue moving through the system looks like end-to-end: user triages, orchestrator dispatches, workspace starts working, workspace finishes its turn, marker fires, issue lands in review.
Implementation: crates/shelbi-cli/src/commands/events.rs.
Reason strings
The reason= tag on task lines is a free-form short token. The system
doesn't enforce a vocabulary, but the orchestrator and CLI use a
consistent set:
| Reason | Source | Meaning |
|---|---|---|
user:cli | shelbi issue move | You moved the card from the CLI with no explicit reason. |
user:cli:start | shelbi issue start | You launched a workspace on an issue from the CLI. |
user:tui:… | the Kanban TUI | You moved the card with H/L in the TUI. |
user:promote | a --reason you passed | Free-form: anything starting with user: reads as "the human chose this." |
orchestrator:auto-dispatch workspace=<name> | the orchestrator's shelbi issue start | The orchestrator picked a free workspace and dispatched per its routing rules. |
workspace:ready-marker | the hub poller | The workspace wrote its ready marker; the poller promoted the issue to review. |
These aren't enforced by code. They're a convention the orchestrator parses to decide whether it triggered an event (and shouldn't react) or you did (and it should respond). The fields the orchestrator pays attention to today:
- A
user:*reason onbacklog -> todomeans "newly triaged." Try to dispatch. workspace:ready-markeronin_progress -> reviewmeans "the assigned workspace just became free." Find it the next issue.orchestrator:auto-dispatch …is the orchestrator's own action; it doesn't react to it (otherwise it would loop).
Whitespace in your reason is replaced with _ so the line stays
parseable on a single token. Newlines and tabs get the same treatment.
Reading the log from code
If you're building something that consumes the log directly, the contract is:
- Append-only. Never truncate; if you need to rotate, do it atomically via rename.
- One line per event, RFC3339 timestamp first, ASCII fields separated by single spaces.
- Every state line leads with a
project=<name>scope; match on the presence of theworkspace=ortask=marker token (they now sit afterproject=), not on a fixed token position. - Reason strings are tokens (no whitespace). If you need to embed
structured data, encode it inside the token (the convention
key=valueworks fine).
The full grammar lives in the append_*_event family in
crates/shelbi-state/src/event_log.rs (append_workspace_event,
append_task_event, append_heartbeat_event, and the rest).
See also
- Workspaces — what each workspace state actually means.
- Workflows — what each
from -> totransition means, including the per-workflow status schema and the default workflow's lifecycle. - Orchestrator — what the orchestrator does with each line it reads.