The events log

View as markdown
Instructions for

Every state change Shelbi observes is appended to one file:

~/.shelbi/events.log

(or <root>/events.log when you've overridden the Shelbi root with --root / $SHELBI_ROOT). 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. At the filesystem layer, it's one stream; per-project views are layered on top. The orchestrator consumes its project's events through a durable, acknowledged cursor (shelbi orchestrator events next / ack), and mirrors the log with shelbi events tail --follow --project <name>, which filters to one project's lines.

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.log is hub-global and workspace names are only unique within a project, so each orchestrator filters to project=<its-own-name>.
  • <prev-state> is none on the first observation of that workspace.
  • States: working, awaiting_input, blocked, paused, and serving (a review slot whose server is up). See workspace states.
  • Blocking-dialog and usage-limit transitions append a reason: reason=dialog:<kind> (and reason=dialog:<kind>:cleared when it goes away), or reason=usage-limit with an optional reset=<hint>.
  • 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> [owner=…] [actions=skipped] [pr=<n> sha=<oid>]
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_agent=developer 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 workflow's status ids, written as-is, except that the stock in-progress is written in_progress (a legacy wire form). The shipped workflows use backlog, todo, in_progress, review, done, and canceled; a custom id like review-app keeps its hyphen. See workflows.
  • from_category=<cat> and to_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. Dispatches append the agent they loaded, e.g. reason=user:cli:start_agent=developer.
  • Optional trailing tokens: owner=me|other:<login>|unassigned|unknown (issue ownership on GitHub-backed boards), actions=skipped (the move ran with --skip-transition-actions), and pr=<n> sha=<oid> on poller:github-merge-reconcile lines.

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) and idle_workspaces=. When Zen Mode is On a zen=on marker is inserted right after heartbeat. While Zen is on, every third heartbeat ends with — <first line of zenmode.md>, and about once an hour with — re-read zenmode.md now to refresh Zen policy.
  • When free disk space under the hub's work_dir runs low, the line also carries disk_free_gib=<n> disk_low=true.
  • Emitted by the hub-side poller on the cadence set by the heartbeat key in project.yaml. Set to "off" to disable.
  • Adaptive cadence: the poller holds at the standard interval (default 60s) while there's supervisable work in flight, then backs off exponentially once the board is quiescent, doubling each idle tick and capping at max (default 5m / 300s). A fully idle hub relaxes 60s → 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 stuck in_progress issue 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) skips that tick's emission (the event already woke the orchestrator) and snaps the cadence back to the standard interval, but only within the current window: a heartbeat still lands at least once per interval, even on a continuously active board.
  • 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:443 with 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 are the orchestrator's fallback wake-up and the stream's liveness signal. When nothing's happening on the board, the orchestrator's 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 Activity feed (in the TUI and the desktop app) 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 (⌘K → Activity, or Go ▸ Activity): the same events this log records, rendered for a human instead of grepped:

my-project
Orchestrator⌘O
Issues⌘I
Changelog⌘L
Workspaces
hub
alphaDeveloper⌘1
bravoDeveloper⌘2
charlieIdle
deltaIdle
devbox
echoDeveloper⌘3
foxtrotDeveloper⌘4
golfIdle
Ready for review3
Cold-start cache⌘5
you/t-011
CSV import fix⌘6
you/t-018
Nightly report⌘7
you/t-024
Queued for review2
Validate webhook payloads⌘8
you/t-023
Harden token refresh⌘9
you/t-025
Zen mode off
Activitymy-project
AllTasksZenWorkspacesSystemmy-projectAll workspaces
Today
charliefinishedCold-start cache⎇ you/t-011[review]12m
alphais buildingDeploy staging env⎇ you/t-009[in progress]18m
bravoawaiting input25m
echois buildingBackfill order index⎇ you/t-017[in progress]40m
youmergedShip dark modemoved to done[done]1h
j/k select enter open Page Up/Page Down page g top G bottom r refresh a/z/w filter

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.

Zen and CI lines

<rfc3339-timestamp> project=<name> mode=zen <prev> -> <new> reason=<source>
<rfc3339-timestamp> zen-dryrun task=<id> action=<action> detail=<detail>
<rfc3339-timestamp> project=<name> task=<id> ci pr=<n> check=<check> conclusion=<conclusion> head_sha=<sha>
2026-06-22T14:40:26+00:00 project=myapp mode=zen off -> on reason=user:palette
2026-06-22T14:52:55+00:00 project=myapp task=fix-login ci pr=42 check=all conclusion=success head_sha=8d3554bd…
  • mode=zen records a Zen Mode toggle. The modes are off, paused, and on; the source is user:cli or user:palette (older logs also show user:hotkey). Older logs may also carry a legacy project=<name> zen=off reason=crash-recovery line.
  • zen-dryrun lines come from shelbi zen dry-run: one per decision Zen would have made. They carry no project= scope.
  • ci lines record a PR's terminal CI verdict for an issue, as Zen's CI watch sees it: check=all conclusion=success when everything passed, or the failing check's name with conclusion=failure, plus the commit it graded.
  • Zen's own board moves are ordinary task lines whose reasons start with orchestrator:zen- (see Reason strings).

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
shelbi events tail -f --project myapp   # only this project's lines (what the orchestrator watches)
shelbi events tail --format envelope    # normalized JSON envelopes instead of raw lines

--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. With --project, -n and --since count within that project's lines. tail reads everything retained, the rotated events.log.1 and then the live events.log (see below).

The follow loop polls every 250ms, holding back the final fragment until its newline arrives so you never see a half-written event. It tracks a logical offset rather than a position in one file, so a rotation between two polls doesn't lose a line. If the log is replaced underneath it, it restarts from the oldest retained line.

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_agent=developer 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=handoff

That 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:

ReasonSourceMeaning
user:clishelbi issue moveYou moved the card from the CLI with no explicit reason.
user:cli:start_agent=<agent>shelbi issue startYou launched a workspace on an issue from the CLI.
user:tuithe Kanban TUIYou moved the card with H/L in the TUI.
user:appthe desktop app's boardYou moved the card in the desktop app.
user:review / user:review-rejectthe review panelYou accepted or rejected a change from the review panel.
user:cli:addshelbi issue addYou created the card from the CLI.
user:cli:resumeshelbi issue resumeYou resumed an issue onto a workspace from the CLI.
user:promotea --reason you passedFree-form: anything starting with user: reads as "the human chose this."
orchestrator:auto-dispatch_workspace=<name>_agent=<agent>the orchestrator's shelbi issue startThe orchestrator picked a free workspace and dispatched per its routing rules.
orchestrator:zen-promote_category=<n>the orchestrator, in Zen ModeZen auto-promoted a backlog issue (<n> is the judgment category).
orchestrator:zen-merge_pr=<n>the orchestrator, in Zen ModeZen merged the issue's PR.
workspace:ready-markerthe hub pollerThe workspace wrote its ready marker; the poller promoted the issue to review.
poller:issue-reconcile / poller:issue-adoptthe hub pollerSomeone moved, closed, or relabeled the issue on the tracker itself (e.g. github.com), or a new tracker issue first appeared in a non-backlog status.
poller:github-merge-reconcilethe hub pollerThe issue's PR was merged on GitHub out of band; the poller advanced the card.

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 on backlog -> todo means "newly triaged." Try to dispatch.
  • workspace:ready-marker on in_progress -> review means "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. Shelbi rotates the file itself at 8 MiB to events.log.1 (one older generation kept) and tracks offsets in events.log.index.json. Don't truncate or rotate it yourself; read events.log.1 too if you need older history.
  • Rotation waits while a registered project that has acked events in the last 7 days still needs events.log.1, up to 32 MiB. Past that, rotation proceeds and each project that lost history gets a line in the new file: project=<name> event-log data-lost reason=<ceiling|idle|unregistered> cursor=<n> earliest=<n> lost_bytes=<n>. shelbi doctor shows a deferred rotation and which projects are blocking it.
  • 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 the workspace= or task= marker token (they now sit after project=), 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=value works 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 -> to transition means, including the per-workflow status schema and the default workflow's lifecycle.
  • Orchestrator: what the orchestrator does with each line it reads.
  • GitHub Issues backend: where the poller:github-merge-reconcile line comes from when a card's PR is merged on GitHub out of band.