# The events log

An append-only file at ~/.shelbi/events.log — every workspace state change and task status move, in one stream the orchestrator tails.

Every 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 task) is the orchestrator's job. At the
filesystem layer, it's one stream.

Defined in `crates/shelbi-state/src/worker_status.rs` (`events_log_path`,
`append_worker_event`, `append_task_event`).

## Line shape

The prefix after the timestamp tells you which kind of event the line
carries:

### Workspace transitions

```
<rfc3339-timestamp> workspace=<name> <prev-state> -> <new-state>
```

```
2026-06-22T14:22:11+00:00 workspace=alpha none -> working
2026-06-22T14:23:47+00:00 workspace=alpha working -> awaiting_input
2026-06-22T14:23:48+00:00 workspace=alpha awaiting_input -> working
```

- `<prev-state>` is `none` on the first observation of that workspace.
- States: `working`, `awaiting_input`, `blocked`. See
  [workspace states](/docs/concepts/workspaces#workspace-states).
- The `workspace=` token names the [workspace](/docs/concepts/workspaces) 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> task=<id> <from-column> -> <to-column> reason=<short>
```

```
2026-06-22T14:24:03+00:00 task=fix-login backlog -> todo reason=user:cli
2026-06-22T14:24:11+00:00 task=fix-login todo -> in_progress reason=orchestrator:auto-dispatch_workspace=alpha
2026-06-22T14:31:52+00:00 task=fix-login in_progress -> review reason=workspace:ready-marker
```

- `<from-column>` and `<to-column>` are the snake_case status names
  from the task's workflow (default workflow: `backlog`, `todo`,
  `in_progress`, `review`, `done`). See
  [workflows](/docs/guides/getting-started/workflows).
- `reason=<short>` is a single token (whitespace is folded to
  underscores) describing who triggered the move.

### Heartbeats

```
<rfc3339-timestamp> project=<name> heartbeat
```

```
2026-06-22T14:25:11+00:00 project=myapp heartbeat
```

- Emitted by the hub-side poller on the cadence set by the
  [`heartbeat`](/docs/configuration/project#heartbeat) key in
  `project.yaml`. Set to `"off"` to disable.
- Adaptive cadence: the poller holds at the standard `interval`
  (default `3m`) 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 `60m`). A fully idle hub relaxes
  `3m → 6m → 12m → … → 60m`, so even a long-quiet board still sweeps for
  a silently-stuck task about once an hour. Backing off on "no
  supervisable work" rather than "no log line" is deliberate: a stuck
  `in_progress` task 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 (a task 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 standard `interval`. 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: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 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 tasks.

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 the second token
(`task=`, `workspace=`, `project=`, `dispatch`, `mode=zen`,
`zen-dryrun`). 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 task move`) and
the poller (when it observes state changes) write to the same file
concurrently. The test
`concurrent_task_and_worker_appends_dont_tear` in
`crates/shelbi-state/src/worker_status.rs` locks this property in.

## Tailing the log

The CLI gives you a `tail -f`-shaped view:

```bash
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.

```text
2026-06-22T14:24:03+00:00 task=fix-login backlog -> todo reason=user:cli
2026-06-22T14:24:11+00:00 task=fix-login todo -> in_progress reason=orchestrator:auto-dispatch_workspace=alpha
2026-06-22T14:24:11+00:00 workspace=alpha awaiting_input -> working
2026-06-22T14:31:50+00:00 workspace=alpha working -> awaiting_input
2026-06-22T14:31:52+00:00 task=fix-login in_progress -> review reason=workspace:ready-marker
```

That five-line burst is what one task moving through the system looks
like end-to-end: user triages, orchestrator dispatches, workspace
starts working, workspace finishes its turn, marker fires, task 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 task move`                       | You moved the card from the CLI with no explicit reason.                           |
| `user:cli:start`                                   | `shelbi task start`                      | You launched a workspace on a task 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 task 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 task 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 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 task.
- `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.
- Two prefixes (`workspace=` or `task=`) keyed off the second
  whitespace-separated token after the timestamp.
- 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 two `append_*_event` functions in
`crates/shelbi-state/src/worker_status.rs`.

## See also

- [Workspaces](/docs/concepts/workspaces#workspace-states) — what each
  workspace state actually means.
- [Workflows](/docs/guides/getting-started/workflows) — what each `from -> to`
  transition means, including the per-workflow status schema and the
  default workflow's lifecycle.
- [Orchestrator](/docs/concepts/orchestrator) — what the
  orchestrator does with each line it reads.
