@tablation/crew
v0.78.0
Published
A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.
Readme
crew
A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back on the board.
Documentation: https://crew.tablation.dev — start with
Getting started. The source is in
docs/.
Each seat is a row in the board's Crew table that happens to be a robot,
with a brief (prompts/<promptSet>/, see "Prompt sets" below) and a slice
of the queue. A seat is named in that table and answers to that name, in the
queue digest and in the comments it writes on tickets. Everything the crew
knows about a run — what to build, what is blocked, what has shipped — is
table data, and every step it takes is visible as a status change or a
comment on the ticket. There is no hidden state and no queue but the board.
Every agent is handed a roster at the top of its prompt: who else is aboard, what each of them is called, and which rows are holds — the people using this ship, and the interactive sessions they work through. A ticket assigned to a hold is off limits to every seat, whatever its status, which is how a person takes something over without racing the crew for it.
Today's crew has four polled seats, described below under the default
prompt set (the three-lane dev/design/QA policy this repo ships with — see
"Prompt sets" below for others, and for writing your own):
| Seat | Owns | Brief |
| --- | --- | --- |
| dev | approved tickets that don't need design work | prompts/default/personas/lane-dev.md |
| design | approved tickets flagged Needs design | prompts/default/personas/lane-design.md |
| qa | everything at fixed or qa, whoever built it | prompts/default/personas/lane-qa.md |
| triage | tickets assigned to the triage seat | prompts/default/personas/lane-triage.md |
A fifth persona, pair, rides crew agents sync alongside these four but
is not polled: it is the identity a live, interactive session (an IDE
conversation, not a scheduled run) uses when it touches the tracker. It has
no Crew-table seat and is never assigned a ticket by the poll loop. crew
agents prompt pair prints its current persona text (a workspace admin's
live edit if there is one, else the local default) for something like a
SessionStart hook to feed into a session at start.
The building lanes' shared policy (worktrees, one stateless invocation per
cycle, the dev/design/QA hand-off) ships as the Crew Lane Agent skill, and
the dev, design and qa briefs include it with a
{{skill: Crew Lane Agent}} marker on their first line. The Agents row keeps
the marker; the include is expanded when a run is assembled, from the
workspace's Agent Skills row if an admin edited it, else the local
skills/*.md file. So an admin edit to a lane's own sentences no longer forks
the shared policy. The marker is {{skill:, a skill name matched exactly after
trimming, }}, on its own line or inline. It is deliberately not a
[[table:key]] record link: personas and skills are not rows of a workspace
table. An unknown name refuses the run; a skill that includes itself (or a
cycle) is expanded once and the repeat left literal with a warning in the run
summary; nesting is capped at 3 levels and 200 KB of included text.
crew agents sync warns about a marker naming a skill the preset doesn't
ship. triage and pair are self-contained (SELF_CONTAINED_ROLES in
src/config.ts): neither cuts a worktree, builds or hands anything to QA, so
the policy would only misdescribe them. A preset forked before this change may
still ship personas/common.md, which keeps being prefixed to the building
briefs. The prompts carry rules, not history; the incidents behind
each rule are kept in docs/prompt-rationale.md.
Alongside personas, a prompt set can also ship skills —
prompts/<promptSet>/skills/*.md, each one YAML frontmatter (name,
description) plus a markdown prompt body. A skill isn't a seat: it's not
polled and not concatenated into every run's prompt, just synced into the
workspace's Agent Skills table so any session can fetch it by name on
demand. crew agents sync [route] pushes both personas and skills in one
run — "agents" here means every agent-shaped resource crew owns, not just
the Agents table; crew skills sync [route] is the same create/update/
diverged sync narrowed to skills alone, for when you've only touched
skills/. crew connect runs this same sync (then the seat key mint) at the
end for an admin key, so a fresh workspace needs no second command; a member
key links seats to the existing rows and is told an admin has to run it once.
--no-agents-sync skips it, and --json adds agents_synced and seat_key
events on stdout. Example use — a Pair session invoked via an Epic's
grill_link looks up the Grill-Me skill through the Tablation MCP tool
agent_skills_controller_get_skill and follows it. prompts/default/skills/
grill-me.md is one shipped example; the rest are the building lanes'
situational procedures (Worktree Setup, Running the Suite, Question and
Investigation Tickets, Filing a Ticket, Ticket Attachments, Workspace
Change Authorization, Design Pass), which the lane policy names at the step
that needs each one — every run keeps the rule and the minimum inline and
fetches the full procedure only when it applies, so a dev run that never
files a ticket never pays for the filing procedure. test/prompts.test.ts
checks that every skill a persona names ships in the same preset.
One role runs per cycle, whichever holds the most urgent actionable ticket
(src/priority.ts decides, and the same module sorts the digest that agent is
handed, so the two can never disagree). QA outranks the building roles whenever
it has anything to check. If the winning role turns out to have nothing it can
actually do, the cycle falls through to the runner-up rather than idling.
Merging and deploying are not an agent's job. After the agent phase, a release phase takes whatever the remote has, squash-merges every QA-verified branch, bumps the version, writes the changelog and runs the repo's deploy hook. A headless session is never handed permission to push to production.
A branch that will not merge is handed back, not skipped. The release
rewinds it, merges the base into the branch in its own worktree, and returns
the ticket to the dev lane with the conflict left in place to look at — so the
seat that resolves it is one with the ticket's context, and QA checks the
resolution like any other change. A branch that was merely stale merges
cleanly at that point and nobody is woken at all. Conflicting a second time
stops at needs_info rather than looping.
Prompt sets
The seats above and their briefs are policy, not mechanism — a fact about
how one particular workspace likes to work, not about what crew itself
can do. prompts/ ships more than one of these as complete, ready-to-run
policies:
| Directory | Policy |
| --- | --- |
| prompts/default/ | Three lanes — dev, design (gated by a Needs design flag), QA. |
| prompts/dev-qa/ | Two lanes — dev builds everything, QA checks it. No design gate. |
A route picks one with promptSet: in crew.yaml (see
crew.example.yaml) — a bare name resolves under this repo's own
prompts/, defaulting to default when omitted. Different routes on the
same ship can run different prompt sets, since which workflow a workspace
wants is a fact about that workspace, not about the machine running it.
Writing your own is the expected path once a shipped preset doesn't
fit: copy a preset directory (cp -r prompts/default ~/my-crew-policy),
edit its prose, and point promptSet at the copy (~/my-crew-policy or a
relative path) — no fork of this repo required. A prompt set is a complete,
self-contained fork of one personas/lane-<role>.md per seat
(and the shared-policy skill the building briefs include), plus whatever skills/*.md it wants, not a diff against another
one; each persona file is read as plain prose ({{skill: Name}} includes expanded
at run time) and concatenated after the
per-run roster/environment sections and before the queue digest
(src/agent.ts's assemblePrompt), so there's no templating layer to
learn. Keep the prose generic: test/prompts.test.ts fails a preset that
names a path, a command, an id, a ticket reference or a date. prompts/dev-qa/ is a worked example of trimming a lane out of
default cleanly, including the unused lane-design.md stub every
preset still needs today — see the next paragraph for why.
One current limit: the role set itself is fixed — src/config.ts's
RoleName (dev, design, qa, triage, pair) — so even a prompt set
with no real use for a polled seat (dev-qa's design) still needs a
lane-<role>.md file for every one of them, pair included, or crew
agents sync and planAgentRun throw looking for it. prompts/dev-qa/
lane-design.md handles this by being a brief that just says "do nothing,
you shouldn't be staffed" — copy that pattern for any polled seat your own
policy doesn't use (pair is never polled, so its own brief just needs to
exist, not disclaim itself). Making the seat set itself policy-defined, so
a prompt set could add or drop a role outright, is a larger change than
prompt sets took and hasn't been done.
What a workspace has to provide
The crew reads its meaning from the board, not from its own source. Statuses,
the priority order, and which column plays which role are per-workspace
facts, because a ship can be connected to several boards that each made
different choices. docs/CONTRACT.md documents the defaults and what a
workspace has to override if it names things differently.
Install
git clone <this repo> crew
cd crew
cp crew.example.yaml ~/.config/crew/crew.yaml
chmod 600 ~/.config/crew/crew.yaml # it holds an API key
$EDITOR ~/.config/crew/crew.yaml
bin/crew doctor # read-only preflightThe config lives in ~/.config/crew/ rather than in the checkout: it describes
the machine, so reinstalling or replacing the checkout does not lose it.
crew looks in $CREW_CONFIG, then $XDG_CONFIG_HOME/crew, then
~/.config/crew/crew.yaml, and only then beside the checkout.
crew connect resolves a workspace/project's ids into the state tree so you do
not have to look them up by hand. doctor then checks the config against
reality: the checkouts, the tools on PATH, the agent binary, the repo's hooks,
and whether the board answers. Nothing writes, wakes an agent or deploys until
a route has enabled: true.
Right after the project resolves, crew connect prints a capability checklist
for the signed-in member (write the Crew and Ships tables, create Repos rows,
mint agent keys, sync personas and skills): ok, or missing — ask a workspace
admin for <capability>. With --json it is one capabilities event on stdout.
Connect carries on for whatever is allowed; misses are stored in the route's
resolved state and crew doctor repeats them until a re-connect finds the grant
in place.
A secondary workspace is addressed as <primary>:<slug>:
crew connect paradium:issues (or paradium:issues/<project>, or
--workspace-id paradium:issues) connects to it, and the route, keychain
entry, seat-key name and repos directory all carry that segment verbatim
(paradium:issues/issues, ~/Crew/paradium:issues/<repo>). Finder shows
the colon in that directory as a slash; --dir overrides the location.
Then install it as a real service:
bin/crew install # writes and loads this platform's own units
bin/crew install --dry-run # see what it would do first
bin/crew uninstall # unload and remove theminstall writes three independent units — run, release and
passengers — and picks the mechanism itself per host: launchd on macOS, a
systemd user unit on Linux (falling back to a crontab line where
systemctl is not usable), or an error on Windows (not built yet). It always
uses an absolute interpreter path (process.execPath) and points each unit's
own log at a different file than the crew's own — pointing both at one file
was hit for real, and doubles every line.
run is a persistent service: crew daemon, the long-running loop that
chains cycles immediately when there's work and backs off when there isn't
(launchd KeepAlive/systemd Restart=on-failure restart it if it crashes).
Installing it starts it, the same as installing any other service would.
bin/crew daemon start|stop|status|restart control that same service
directly — useful without a full reinstall — except on the plain-crontab
fallback, which cannot supervise a persistent process and so keeps the old
one-shot-per-fire crew run instead. release and passengers stay on
their own fixed timers/crontab lines, one-shot per fire same as always: the
first run of each happens one interval after install, and each fire is a
few API calls — a full agent session only starts when that cheap check finds
something worth waking for.
The daemon also updates itself: on an idle pass (never mid-cycle) it checks
whether its own installed files (src/dist/bin, or the binary itself for
a compiled install) have changed since it started, and if so exits cleanly —
the same crash-recovery restart above then relaunches it against whatever is
now on disk. crew daemon restart forces this immediately, for a manual
git pull && crew daemon restart instead of waiting for the next idle tick.
launchd/com.tablation.crew.plist is kept only as a reference for what
install generates — hand-editing and loading it directly still works, but
crew install/crew uninstall is the supported path now.
Commands
crew poll [route] decide a cycle and report it; writes nothing
crew run [route] [--role R] run the winning role's session, then release
crew daemon [route] run the persistent supervisor loop in the foreground
crew daemon start|stop|status control the installed `run` service directly
crew daemon restart stop then start it (force-picks-up an update)
crew release [route] merge what QA verified, version it, ship it
crew merge [route] merge verified branches and stop
crew deploy [route] release now, even with nothing new to merge
crew watch [route] live view of what the crew is doing
crew status [route] paused/running state, led by what the operator owes (needs_info assigned to them, needs_planning, ships needing attention)
crew doctor [route] [--fix] preflight; --fix enables clean routes, offers crew install
crew selftest [route] [--toolchain NAME] [--json] launch each toolchain as a run would and prove it reaches the tracker
crew sample-ticket [route] [--remove] [--dry-run] [--json] file one ticket on a throwaway repo that walks the dev, QA and release loop
crew ports [route] which checkout owns which ports, and what is up
crew reap [route] kill servers left behind by removed worktrees
crew drop [route] NNN remove a merged ticket's worktree and branch
crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
crew sync [route] fast-forward the checkout and its worktrees from the remote
crew pause|resume [route] [R|release] pause everything, one role, or releases only (passengers keep running)
crew log [route] tail the log
crew inbox [--member NAME] your tickets across every workspace
crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
crew repos add [ROUTE] [NAME] [PATH] attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
crew repos check [PATH] validate a checkout's .crew.yaml (parse, release readiness, hook syntax) without attaching it (--route ROUTE, --strict, --json)
crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--write route|repo [--replace] writes them, --clear route removes them, --recipes, --hook KEY=CMD, --agent, --json)
crew install [--replace] write and load this platform's scheduler unit (macOS: clears a crew installed from another path)
crew uninstall [--all] unload and remove it (--all: every crew launchd unit, whichever path it came from)Every command that could change something takes --dry-run.
One ship, many boards
A ship connects to several workspaces at once, and ranks their queues against
each other — a P0 on one board outranks a P2 on another. The ship is identified
by ship.name matching a row in each board's Ships table; a crew member is
identified by email, so the same person is recognised across workspaces.
An area of development spans several repositories, so a checkout is a
property of the ticket, not of the route: repos: maps each name in the
board's Repos table to a directory on this machine. Naming even one repo
there makes it a closed list — several ships can divide a multi-repo area's
work between them, and anything not named is deliberately another ship's,
marked NO CHECKOUT in the digest rather than left blank (blank would read
as "work it here", which is the wrong directory).
Leaving repos: out entirely means the opposite: this ship serves
everything the board's Repos table lists, each checkout defaulting to
<reposBasePath>/<workspace>/<repoName> — reposBasePath is ~/Crew
unless a ship: or route-level reposBasePath: says otherwise. A repo
that doesn't exist yet at its derived path is cloned there automatically
the first time a ticket actually needs it (from the Repos table's own
remote column, owner/repo over SSH) — not up front on crew connect,
which only discovers names and remotes, never fetches anything itself.
What lives where
bin/crew the entry point
src/ the implementation (Node, run directly via type stripping)
test/ its tests, including a fake board for integration runs
(the suite must pass with no crew.yaml, as on CI: a CLI-level test sets
CREW_CONFIG to a missing file instead of relying on the machine's own)
prompts/ prompt sets — prompts/<name>/personas/lane-<role>.md
+ prompts/<name>/skills/*.md
docs/ CONTRACT.md, REPO_SPEC.md, MIGRATION.md
launchd/ timer templates (edit the paths before installing)
crew.example.yaml copy to ~/.config/crew/crew.yamlThe crew carries no ids, no absolute paths, no project commands and no device
names. Anything specific to a machine belongs in crew.yaml; anything
specific to a repository belongs in that repository's own .crew.yaml
(docs/REPO_SPEC.md), which declares its platform, hooks, branch naming,
versioning and release mode:
| hook | required | what it is for |
| --- | --- | --- |
| test | yes | run the suite before a release |
| build | yes | build it |
| deploy | when release.mode: local | ship it |
| setup | no | prepare a fresh worktree |
| ports | no | print this checkout's port assignments |
| version | no | print the current version |
| bump | no | produce and print the next one |
| merged | no | ask the forge whether a branch actually landed |
| released | no | confirm a commit is live (a sha, or an exact version) |
The crew carries no commands of its own: everything it runs to test, build, version or ship a repository comes from that repository's own file.
One hook is the exception and lives in crew.yaml instead: notify, which is
handed a level (ok / warn / fail), a headline and a detail line whenever a
release ships or is blocked. Where that should be shown — a desktop
notification, a status widget, a webhook, a push — is a fact about the machine
and the person watching it, not about the code. Define none and release state
simply goes to the log.
A repo's own .crew.yaml wins over anything repeated in crew.yaml, and
crew doctor reports the duplication as drift. The client can still configure
a repo that has no .crew.yaml of its own.
Status
Live on macOS, driving this project's own board. Triage is a seat, not a second process — there is one timer to install, not two.
The bash implementation this replaces has been deleted. Where its behaviour was
the only specification of something — the priority matrix, the QA digest, the
crew labels — it was captured as a fixture under test/fixtures/ first, so the
tests still hold the port to what the original did.
Outstanding: Linux has not been exercised end to end, and the API key sits in
crew.yaml in plaintext until the device-code flow and keychain storage land.
