Skip to content

Beads WorkPlane adaptor — mapping notes

Beads WorkPlane adaptor — mapping notes

Source: internal/adapter/bd/. Adaptor id: beads.

This document describes how the Beads (bd) CLI is projected onto the core.WorkPlane interface. For the contract itself, see workplane.md. This note focuses on the mapping rules that matter when reading or debugging the adaptor.

Status → StateCategory

Declared in types.go (beadsStateMap). Covers every status bd can emit today. A bead whose status is outside the map is placed in backlog and the port logs a validation warning; the adaptor-startup CapabilityManifest.Validate() call fails fast if a known bd token is missing.

bd statuscore.StateCategory
openunstarted
in_progressstarted
hookedstarted
pinnedstarted
blockedstarted
deferredbacklog
closedcompleted

staged is a Gemba-level convention because Beads has no native staged status: the adaptor writes status=open plus the staged:true label, and reads that combination back as core.StateStaged.

WorkItemID encoding (DD-6)

bd ids are bare (e.g. gm-abc). The adaptor prefixes them with the configured workspace chunk (default gemba/gemba) when projecting onto core.WorkItemID, and strips the prefix before handing a native id back to bd. nativeID accepts any /-separated path whose last segment is the bd id so conformance runs that mint ids as gemba/gemba/gm-... under every adaptor keep working.

Agent federation (gm-e6.3 / DD-1)

bd has a single scalar assignee field and a flat label bag. The adaptor synthesizes a richer core.AgentRef by combining assignee (the id) with convention-named labels (the federated fields).

Read path — label → AgentRef

LabelAgentRef fieldNotes
agent:role:<role>RoleFree-form string. polecat, witness, crew, etc.
agent:parent:<id>ParentIDExpected to be a workspace-qualified AgentID.

A bead with an assignee and no federated labels still yields a valid AgentRefRole stays blank and ParentID stays nil. A bead with no assignee yields a nil AgentRef, regardless of what labels are present. Synthesis is permissive; missing metadata is never fabricated.

AgentKind is always agent. Beads’s assignee slot is used exclusively by automated actors in the gemba ecosystem; humans live in the owner field (projected separately as AgentKindHuman). If a future Beads convention lets a human claim a bead, the extension point is an agent:kind:* label — not in scope for gm-e6.3.

Write path — AgentRef → label

CreateWorkItem and UpdateWorkItem rewrite the agent:role:* / agent:parent:* portion of the label set from wi.Assignee. The AgentRef is authoritative for those two keys:

  • any agent:role:* or agent:parent:* value in the caller-supplied Labels slice is stripped first, then the AgentRef’s values are added. Callers cannot accidentally ship an AgentRef that contradicts its own label encoding.
  • non-agent labels pass through unchanged.

Update has two shapes for label writes:

  1. patch.Labels populated → --set-labels is used, meaning the caller’s slice (with agent labels merged in) fully replaces the bead’s label set. Use this when you want a clean state.
  2. patch.Labels empty, patch.Assignee carries federated metadata → --add-label is used (additive). An assignee re-claim must not silently erase every other label on the bead, so the adaptor never converts an “assignee-only” patch into a --set-labels write.

Consequence: mode (2) can leave stale agent:role:* / agent:parent:* labels behind when the previous assignee’s values differ. Callers that care about stale-label hygiene should pass an explicit Labels slice (mode 1). The adaptor documents this rather than paying a bd round-trip to read existing labels on every assignee-only patch.

Round-trip guarantee

Setting Assignee = {ID, Role, ParentID} on a WorkItem, pushing it through UpdateWorkItem, and reading it back via GetWorkItem produces an identical AgentRef. Tests live in internal/adapter/bd/agents_test.go — that file is the executable contract for this section.

Extension channel (gm-e6.1)

core.WorkItem.Custom carries Beads-specific fields that have no core counterpart under the beads: namespace:

KeySourcePurpose
beads:issue_typeissue_type`task
beads:notesnotesFree-text field bd writes independently of description.
beads:parentparentParent bead id for hierarchical children.
beads:created_bycreated_byActor string for the initial create.
beads:started_atstarted_atLifecycle timestamp.
beads:dependenciesdependenciesRaw native-edge rows (gm-e6.2 decodes the mapped subset).
beads:dependentsdependentsRaw native-edge rows in the inverse direction.

The SPA renders these under web/src/extensions/beads/. Adding a new extension field is a two-step change: declare it in beadsManifest.FieldExtensions and surface it in the renderer.

Mutation boundary (DD-9)

All writes shell out to the public bd CLI. The adaptor never touches .beads/*.db directly — that store’s shape changes across bd versions, and the CLI is the stable contract. This rule is asserted by conformance group F.

Create and edit map to bd create and bd update. Hard delete is available as a Beads-specific extension through bd delete <id> --force; Gemba reserves that for explicit delete actions in Beads management surfaces. Closing finished work remains a state transition through bd update / bd close, not a delete.

Cross-process mutations — the post-write hook (gm-e4.3.3)

bd mutations made outside the running gemba serve process — a terminal bd close gm-foo, an ops runbook, a second editor pane — don’t flow through the in-process core.WorkPlaneEmitter, so /events SSE subscribers wouldn’t see them without help. The fix is a small post-write hook installed alongside bd that POSTs the changed bead’s id at /api/workitems/notify (gm-e4.3.2). Gemba re-reads the bead through its bound WorkPlane and publishes a normal workitem.* GembaEvent — same kind, same envelope, same SSE path as in-process mutations.

Wire shape

The hook is a dumb trigger; the body is intentionally minimal.

POST /api/workitems/notify
Content-Type: application/json
Authorization: Bearer <token> # only if --auth=token is on
{
"work_item_id": "gemba/gemba/gm-foo",
"source": "bd-git-hook" # optional echoed onto the event payload
}

Gemba ignores any other state in the body — the canonical bead is re-read from the WorkPlane on every notify, so a stale or maliciously crafted body cannot poison the cache. The handler returns 200 OK with {work_item_id, kind, skipped?} so the hook can log what landed.

Idempotency

The handler dedupes on (work_item_id, UpdatedAt). A duplicate POST for the same bead at the same UpdatedAt returns 200 OK with skipped: true and does NOT republish. This means a hook-side retry loop is safe; a re-run after a real mutation still publishes (the UpdatedAt advances).

The dedup window is bounded (FIFO, 1024 entries by default). On overflow the oldest id evicts and the next notify for that id will re-emit — false-negative on the dedup, never a false-positive on the event. Operators don’t need to tune this.

Installing the hook

Status (2026-04-25): the gemba-side companion binary (bin/gemba-bd-hook) ships in this repo today (gm-e4.3.3, this bead). The proper post-write hook point in bd itself remains external — bd hooks install --gemba-url ... is the eventual shape, blocked on an upstream PR. Until then the binary is wired via cron / wrapper alias / manual invocation; see the section below.

The hook is invoked by bd after every successful write that touches a bead’s UpdatedAt. It reads two values from its environment:

VariableRequiredMeaning
GEMBA_NOTIFY_URLyesBase URL of gemba serve, e.g. http://localhost:7666.
GEMBA_NOTIFY_AUTHonly when --auth=tokenBearer token mounted on every request.

Without GEMBA_NOTIFY_URL set, the hook is a no-op — the same bd binary works on a machine that doesn’t run gemba locally.

Fail-open contract

A hook-side error (gemba is down, network drops, auth misconfigured) MUST NOT fail the underlying bd mutation. The hook logs to stderr and exits 0. This is non-negotiable: the operator’s terminal write is authoritative, and the SSE pump is a convenience. A loud warning on stderr is the upper bound on user-visible breakage.

Verifying the round trip

In one terminal, watch the SSE stream:

Terminal window
curl -N http://localhost:7666/events?topics=workitem.*

In a second terminal, mutate a bead:

Terminal window
bd close gm-foo

Within ~250ms the first terminal should print a workitem.closed (or workitem.updated) event with the bead’s canonical id. If nothing arrives:

  • Check GEMBA_NOTIFY_URL is set in the second terminal’s env.
  • Check gemba serve is running on that URL.
  • Run bd hooks list to confirm the gemba hook is installed.
  • Tail gemba serve’s log — auth failures and adaptor-degraded errors surface there.

The post-write hook is the only piece of cross-process plumbing between bd and gemba. Anything more sophisticated (Dolt binlog tail, polling shim) would replace this hook, not stack on top of it.

Companion binary: gemba-bd-hook

The id-detection + POST + retry logic ships as a small Go binary at bin/gemba-bd-hook (built by make build alongside the other sentinel CLIs). The intended long-term path is for bd hooks install to drop a one-line shim that invokes this binary — keeping the upstream PR small and the heavy lifting in this repo. Until that upstream PR lands the binary is wired manually.

Three id-collection modes:

Terminal window
# Explicit ids — simplest, ideal for ad-hoc scripts.
gemba-bd-hook --id gm-foo --id gm-bar
# Read ids from stdin — convenient with bd query pipelines.
echo gm-foo | gemba-bd-hook --stdin
# Detect ids from a Dolt diff. Run from the bd workspace dir.
gemba-bd-hook --from-dolt-diff HEAD~1

Four install patterns work today (in decreasing order of coverage):

  • Watch mode (recommended; gm-1890) — long-running daemon:

    Terminal window
    GEMBA_NOTIFY_URL=http://localhost:7666 \
    gemba-bd-hook --watch ~/gt/gemba/.beads &

    fsnotify on <bd-dir>/issues.jsonl (bd’s auto-export target, enabled by default). Catches every bd write within ~3s of the auto-export throttle window — including bare bd update and any other tool that goes through bd’s public API. Exits cleanly on SIGINT / SIGTERM. Use a systemd unit / launchd plist / & in a tmux session per operator preference.

  • Git post-commit hook (gm-1890) — drop a one-line .git/hooks/post-commit that calls this binary:

    Terminal window
    gemba-bd-hook --install-git-hook ~/gt/gemba

    The installer follows .git-as-file pointers (worktrees, submodules) and is idempotent. Fires only on git commit in the source repo — narrowest coverage, but useful for teams that auto- commit .beads/issues.jsonl after every bd write.

  • Cron* * * * * cd <bd-dir> && gemba-bd-hook --from-dolt-diff HEAD~1. Coarse-grained (1-minute lag); catches everything regardless of caller. No daemon process required.

  • Wrapper alias — alias bd to a shell function that calls the real bd, captures the exit, and on success calls gemba-bd-hook --from-dolt-diff HEAD~1. Catches terminal invocations; misses other tools. Useful when a daemon-style process isn’t desirable.

  • Manual — append gemba-bd-hook --id <id> to any script that already detects bd writes. Lowest-friction one-off.

Fail-open is the default. Pass --strict for paths where a silent drop would hide real problems. With GEMBA_NOTIFY_URL unset the binary is a silent no-op so a hook script can call it unconditionally on machines that don’t run gemba locally.