Bee

A dependency-aware work-coordination engine for Elixir — over SQLite.

Bee models units of work and the relationships between them: a dependency DAG that knows what blocks what, an allocation tree of projects and agents, and a query language that does not just return results but tells you what it withheld and how to ask again. It is designed for programs and agents that need to track, query, and reason about work — not just store it.

Bee persists to its own SQLite database and owns its schema. No consumer creates, alters, or drops objects in it. The boundary is the namespace.


Installation

The Hex package name is bee_tracker; the OTP application remains :bee and the public modules remain Bee.*. Add it to your dependencies under the package alias:

{:bee, "~> 0.3.0", hex: :bee_tracker}

Then fetch and compile:

mix deps.get
mix deps.compile

Bee requires Elixir 1.19+ and a working SQLite (bundled via exqlite).


Quick start

Bee is a supervised process. Add it to your application's supervision tree:

children = [
  {Bee.Supervisor,
   db_path: "priv/bee.db",
   prefix: "bee",
   jsonl_path: "priv/bee.jsonl"}   # optional — a derived, append-only export
]

Supervisor.start_link(children, strategy: :one_for_one)

prefix namespaces the internal ids (bee-1, bee-2, …) so several bee instances can share analysis tooling without colliding. You address issues by their plain integer — bee resolves the prefix for you.

Create, block, and find ready work

{:ok, auth} = Bee.create("Implement auth", priority: 2, project_id: "api")
# => {:ok, %{id: 1, title: "Implement auth", status: "open", ...}}

{:ok, billing} = Bee.create("Add billing", priority: 3, project_id: "api")
# billing depends on auth — billing is blocked until auth closes
:ok = Bee.block(billing.id, auth.id, type: :blocks)

{:ok, ready} = Bee.ready()
# => {:ok, [%{id: 1, title: "Implement auth", ...}]}   # billing is not ready

{:ok, [^auth | _]} = Bee.critical_path()

Query, and read what was withheld

{:ok, %{issues: issues, withheld: withheld, refine: refine}} =
  Bee.query(status: "open", project_id: "api", limit: 5, detail: :compact)

# withheld => %{limit: 42}            # 42 more issues exist beyond the limit
# refine  => [offset: 5]              # request the next bounded page

Four detail levels shape each result — :minimal | :compact | :standard | :full — so a list view doesn't pay for full descriptions, and a detail view can.

Query limits are capped at 500; page with offset instead of requesting an unbounded result. Queries also compose plain-text search, multiple projects, dependency readiness, and recency ordering in one database operation:

{:ok, %{issues: recent_track_work}} =
  Bee.query(
    text: "Northwind",
    project_ids: ["api", "web"],
    status: nil,
    ready: true,
    order_by: [updated_at: :desc],
    limit: 10,
    detail: :minimal
  )

Ask in your own words

# A built-in intent
{:ok, %{issues: top}} =
  Bee.ask(:what_next, agent: "mercury", project: "api", limit: 3)

# Register your own — a named, stored query spec
:ok = Bee.register_intent("stale-and-open", status: "open", order_by: [created_at: :asc])
{:ok, %{issues: stale}} = Bee.ask("stale-and-open")

# Discover stored vocabulary instead of guessing it
{:ok, intents} = Bee.list_intents()
{:ok, measures} = Bee.list_measures()

Measure, then roll up effort

:ok = Bee.register_measure("effort", "hours", domain: :non_negative)

:ok = Bee.measure(auth.id, %{
  measure: "effort",
  value: 6.0,
  dims: %{"kind" => "estimate", "agent" => "mercury"}
})

# Sum declared effort across the whole subtree under a node
{:ok, rollup} = Bee.rollup(epic.id, scope: :tree, kind: :estimate, meaning: :to_complete)
# => {:ok, %{total: 22.0, covered: 18, missing: 0, ...}}

Rollup scopes: :tree (subtree by parent), :closure (dependency transitive closure), :critical_path (the longest weighted path). If some nodes lack a measurement, bee reports total: nil, a partial_total, and a refine hint listing exactly which ids are missing — it never silently invents a number.


Core concepts

The dependency vocabulary

Not all relationships gate. Bee refuses meaningless dependency types at the boundary — each one carries a distinct meaning:

Type Class Meaning
:blocks gating B can't start until A closes
:waits_for gating B waits for all of A's children
:conditional_blocks gating B runs only if A fails
:parent_child structural parent blocked ⇒ children blocked (derived from parent:)
:related non-gating loose "see also"
:discovered_from non-gating provenance
:replies_to non-gating threading

Only gating edges feed ready/0, critical_path/0, and dependency rollups. Traversal honours the direction you ask for:

{:ok, blockers} = Bee.traverse(id, direction: :blockers)    # why is this gated?
{:ok, dependents} = Bee.traverse(id, direction: :dependents) # what does this gate?

# A bounded blocker-to-goal chain, or a project-scoped global path
{:ok, chain} = Bee.critical_path(root: id, depth: 30)
{:ok, project_path} = Bee.critical_path(project_id: "api", status: "open")

The allocation tree

Alongside the dependency DAG, bee tracks who works on what:

:ok = Bee.register_project("api", name: "API service")
:ok = Bee.register_agent("mercury", name: "Mercury", type: "worker")
:ok = Bee.join_project("mercury", "api")
:ok = Bee.assign(auth.id, "mercury")

{:ok, projects} = Bee.list_projects()          # includes issue_count
{:ok, agents} = Bee.list_agents()

{:ok, load} = Bee.agent_load("mercury")          # how many open issues?
{:ok, pairs} = Bee.who_blocks_whom()              # which agents gate each other?
{:ok, bns} = Bee.bottlenecks()                    # overloaded agents + their projects

Measurements: measures × dimensions

A measure is numeric and calibratable (effort, tokens, cost). A dimension is categorical and groupable (agent, model, branch). Bee never interprets dimension values — it partitions on them, which makes it simultaneously agnostic and useful.

:ok = Bee.register_measure("tokens", "tokens")
:ok = Bee.measure(billing.id, %{
  measure: "tokens",
  value: 1840,
  dims: %{"kind" => "actual", "model" => "claude", "agent" => "mercury"}
})

Every measurement carries a "kind" dimension ("estimate" or "actual") so declared plans can later be measured against what was earned. Dimensions are schemaless JSON but indexable on demand — arbitrary vocabulary, no migration, fast GROUP BY.

The event log

Every mutation — create, update, comment, block, unblock, assign, lock, measure — is written to an append-only events table with a per-issue monotonic sequence. It is bee's autobiography, captured with nobody opting in. Tail it by the global id cursor, or replay an issue's history by sequence.

Locks

{:ok, lock} = Bee.lock(auth.id, locked_by: "mercury", ttl: 30)   # 30-minute hold
{:error, :already_locked} = Bee.lock(auth.id, locked_by: "venus")
:ok = Bee.unlock(auth.id)

Locks carry a TTL and a holder. A background sweeper releases expired locks; each expiry is recorded as an event. force: true takes the lock from its current holder.


Two consumption surfaces

Bee supports two equal, disjoint interfaces:

  1. The Bee.* module API — the in-process facade shown throughout this README. Structural errors raise ArgumentError in your process for good ergonomics.
  2. The Bee.Repo GenServer message protocol — a versioned wire contract for node-crossing consumers ({:create, title, opts}, {:ready, opts}, …). Invalid messages return {:error, reason} and never raise inside the writer — a raise there would kill the single writer and every queued command behind it.

A change safe for one surface is proven safe for the other.


Configuration

Bee.Supervisor accepts:

Option Required Default Notes
:db_path yes — Path to the SQLite database file.
:prefix no "bee" Namespace for internal ids.
:jsonl_path no nil If set, bee maintains a derived JSONL export (debounced, flushed on orderly shutdown).
:name no Bee.Supervisor Supervisor name.
:repo_name no Bee.Repo Repo GenServer name — pass it to the server arg of any Bee.* call.
:pool_name no derived Reader-pool supervisor name.
:sweeper_name no derived Lock-sweeper name.

You can also start a standalone Bee.Repo directly (handy in tests):

{:ok, _pid} =
  Bee.Repo.start_link(db_path: db_path, prefix: "test", jsonl_path: nil, name: :my_bee)

Architecture notes


Import / export

{:ok, count} = Bee.import_jsonl("priv/bee.jsonl")

The JSONL export is one object per issue (with its comments and measurements). On first boot, if the database is empty and jsonl_path exists, bee seeds from it — so the file is both a backup and a portability format.


Testing & development

mix test
mix docs         # build this README into HexDocs
mix hex.build    # build the package tarball

License

MIT — see the LICENSE file.