laravel-kanban maintained by petar-spasic
laravel-kanban
A git-backed kanban board, a worktree-per-card workflow with its own Docker stack, and the Claude Code hooks, agents and guard that let a main session run parallel agents safely. Dev-only, for Laravel projects.
- The board is the plan of record. JSON files on an orphan branch
kanban, checked out atdocs/kanban. Every change is one commit, made by the CLI, the local UI or a hook — never by hand. - One card = one worktree = one stack.
kanban startclaims a card, creates.claude/worktrees/xxxxxx-<slug>on its own branch, writes a.envwith its own ports and brings up its own compose project. The worktree, the compose project, its containers and the agent's description carry the card's id and title, so what is being worked on reads at a glance indocker psand the agent list. - Agents are fenced. A PreToolUse guard and git hooks let a subagent commit only inside its own worktree; push, pull, reset, checkout, worktree commands and board edits are the main session's.
- Done is proven. A skeptical, read-only evaluator verifies every acceptance criterion;
finishmerges only an approved head;publishpushes once per run.
Requirements
- PHP 8.3+, Laravel 12 or 13
- git ≥ 2.42 (
git worktree add --orphan) - Docker Compose v2 for worktree stacks (optional: without a compose file cards get a worktree only)
- Claude Code for the agent workflow; Laravel Boost optional (it installs the guideline and the
kanbanskill)
Install
composer require --dev petar-spasic/laravel-kanban:^0.1
php artisan kanban:install --key=KEY # --dry-run prints what would change
vendor/bin/kanban doctor # ok|warn|fail per check, exit 1 on any fail
Then restart Claude Code (agents and hooks load at session start), and run vendor/bin/kanban lease --takeover if the
old session holds the orchestrator lease.
kanban:install (main checkout, once per project):
- creates the orphan branch
kanbanatdocs/kanbanwith a stub board and commits it; - configures this machine: the JSON merge driver,
core.hooksPath→ the package'sgithooks/, andkanban.rejectCoAuthoredfromgithooks.reject_co_authored; - merges
.claude/settings.json(hooks below,permissions.allow+=Bash(vendor/bin/kanban *), and commit and PR attribution off while Co-Authored-By trailers are rejected); foreign hooks are kept; - writes
.claude/agents/kanban-worker.mdandkanban-evaluator.md(only files carrying its marker are overwritten), withmodel/effortfromkanban.agents; - with
boost.json: addspetar-spasic/laravel-kanbantopackagesand runsboost:update(guideline intoCLAUDE.md, skill into.claude/skills/kanban); without Boost: a<!-- laravel-kanban:start/end -->block inCLAUDE.md; - adds
/docs/kanban/and/.claude/worktreesto.gitignore.
Everything on the main branch is left for you to review and commit. Every other machine or clone:
composer install && vendor/bin/kanban attach.
Upgrade: composer update petar-spasic/laravel-kanban, vendor/bin/kanban doctor --fix (agents, hooks, git config),
php artisan boost:update (with Boost), then restart Claude Code.
The board
docs/kanban/kanban.json {"version":1,"key":"KEY","id_length":6,"max_parallel":6,"ready_buffer":12,
"wip":{"review":6},"stale_after_minutes":20,"guard":{"strict":false,"main_write_paths":[]}}
docs/kanban/<epic>/epic.json {"title","goal","done_when":[…],"body","order"}
docs/kanban/<epic>/<board>/board.json {"title","kind":"work|decisions","body","order","wip":{"doing":6}}
docs/kanban/<epic>/<board>/KEY-XXXXXX.json
{ "id": "KEY-XXXXXX", "type": "feature", "title": "Export invoices as CSV", "stage": "ready", "priority": "high",
"labels": ["area:billing"], "body": "## Context\n…",
"acceptance": [{ "id": 1, "text": "GET /invoices.csv lists the month's invoices", "done": false }],
"depends_on": ["KEY-YYYYYY"], "blocked": null, "claim": null, "work": null,
"created": "2026-09-28T19:00:00.000+00:00", "updated": "…", "log": [] }
- Stages — work boards:
backlog → ready → doing → review → done(+dropped); decision boards:proposed → decided → superseded(+dropped). Decided cards are binding. - Types
feature|bug|chore|spike|decision; priorityurgent|high|normal|low(urgentmay exceed capacity by 1). - IDs
KEY-+ 6 Crockford base32 characters, random; any unique prefix of ≥ 3 characters is accepted. - Rules — unknown keys are errors; 1–12 acceptance criteria to enter ready;
depends_onacyclic;claim/workonly in doing/review;logappend-only.kanban validate [--fix]checks all of it. - Files are canonical pretty JSON with a field-group-aware merge driver, so two machines editing the board converge.
Workflow
owner ── /kanban UI (main stack, local only) ─┐
main session ── vendor/bin/kanban … ──────────┼──> docs/kanban (branch kanban, one commit per write)
hooks (SessionStart, SubagentStart/Stop, …) ──┘
│ kanban start KEY-XXXXXX claim · worktree · .env · port slot · compose up -d
▼
.claude/worktrees/xxxxxx-<slug> branch card/key-xxxxxx-<slug> project {{app}}-wt-xxxxxx-<slug> ports 2101x
▲ spawned with isolation: worktree → WorktreeCreate hands it this worktree; EnterWorktree(path) → the guard binds it
kanban-worker (background): commits on its branch → kanban report … (staged)
│ SubagentStop: clean tree, ≥ 1 commit, gates.report → applied → stage review
kanban refresh (merge main into the branch) → kanban-evaluator (read-only) → kanban verdict …
│ approve → kanban finish: merge --no-ff · done · stack down · slot freed · worktree + branch removed
▼ end of run: kanban publish → git push origin kanban main
The main session follows the kanban skill (run loop, decisions, recovery). Every command, flag, exit code and
transition is in references/protocol.md;
vendor/bin/kanban <cmd> runs without booting the app, and the same classes run as php artisan kanban:<cmd>. In a
code worktree it runs the main checkout's copy: the worktree's vendor/ dates from its card's start.
Hooks
Merged into .claude/settings.json (exec form, so paths need no quoting):
| Event | Handler | Does |
|---|---|---|
| SessionStart | kanban hook session-start |
attach if missing, flush, retry the inbox, export KANBAN_SESSION, print the brief |
| SubagentStart | kanban hook subagent-start |
register kanban agents, add the board rules to their context |
SubagentStop (kanban-worker|kanban-evaluator) |
kanban hook subagent-stop |
refuse a stop without a report, run the gates, apply the report or verdict |
PreToolUse (Bash|Monitor|Edit|Write|NotebookEdit|EnterWorktree|ExitWorktree|Agent) |
bin/kanban-guard |
plain PHP, < 50 ms; binds agents to cards, denies writes outside the own worktree, board edits and non-own git |
| WorktreeCreate / WorktreeRemove | kanban hook worktree-create|remove |
a kanban agent the guard saw spawned gets its card's worktree; claude -w <ID> too; anything else a fresh worktree with deps, .env and a port slot. Card worktrees are never removed by the hook |
Git hooks (core.hooksPath): commit-msg rejects Co-Authored-By trailers unless githooks.reject_co_authored is
off; pre-push rejects pushes from a code worktree.
Agent isolation
Both agents carry isolation: worktree. The guard records each kanban spawn (.git/laravel-kanban/spawns/<ID>.json)
and the WorktreeCreate hook hands the isolated subagent the worktree of the oldest record (≤ 2 min old), so it starts
pinned there; two isolated spawns in one message could swap worktrees, which the agent's first EnterWorktree(path)
corrects. When Claude Code refuses EnterWorktree, the guard has bound the agent anyway (guard-only mode: absolute
paths, cd <worktree> && …, writes outside its worktree denied).
Worktree stacks
Each worktree runs the project's own docker-compose.local.yml (stack.compose_file) under its own project name,
with ports from a machine-wide registry (~/.local/state/laravel-kanban/stacks.json, slots 1–99 × 10 ports from 21000):
# written into .claude/worktrees/xxxxxx-<slug>/.env (main's .env minus these keys)
COMPOSE_PROJECT_NAME={{app}}-wt-xxxxxx-<slug>
WEB_PORT=21010
DB_HOST_PORT=21011
REDIS_HOST_PORT=21012
DB_PORT=21011 # host artisan and tests hit this stack
SIDECAR_BIND=127.0.0.1
LOCAL_APP_URL=http://203.0.113.10:21010
SESSION_COOKIE={{app}}-wt-xxxxxx-<slug>-session
The compose file must work for many stacks at once:
name: "${COMPOSE_PROJECT_NAME:?COMPOSE_PROJECT_NAME unset - main: add it to .env; worktree: vendor/bin/kanban stack create}"
services:
app:
build: . # no image: on a built service
ports: ["${WEB_PORT:-{{web_port}}}:80"] # every published host port from a stack.ports variable
postgres:
image: postgres:16-alpine
ports: ["${SIDECAR_BIND:-0.0.0.0}:${DB_HOST_PORT:-{{db_port}}}:5432"]
# never: container_name, a volume or network name: not derived from ${COMPOSE_PROJECT_NAME}
doctorfails oncontainer_name, a fixed host port, a volume or networkname:not derived from${COMPOSE_PROJECT_NAME},image:on a built service, a top-levelname:without${COMPOSE_PROJECT_NAME:?…}, and a main.envwithoutCOMPOSE_PROJECT_NAME({{app}}-local);doctorwarns whenphpunit.xmlsetsDB_HOST/DB_PORT(tests in a worktree would hit main's database) and onexternalvolumes with a fixed name in the otherdocker-compose*.ymlfiles, which miss the project-scoped local volumes (<project>_<volume>);- main's ports stay outside 21000–21999; Vite must not watch
.claude/worktreesanddocs.
Docker address pools
Every stack is one Docker network. When the host's LAN overlaps Docker's default address pools, only a few networks stay free, about 6 stacks machine-wide. Widen the pools once (sudo), then bring the stacks back up (compose stacks without a restart policy stay down):
// /etc/docker/daemon.json
{ "bip": "172.17.0.1/16",
"default-address-pools": [ { "base": "10.210.0.0/16", "size": 24 }, { "base": "172.16.0.0/12", "size": 16 } ] }
sudo systemctl restart docker
vendor/bin/kanban doctor # "docker address pools: N free networks" (warns below 6)
stack.max_stacks (default 6, KANBAN_MAX_STACKS) caps stacks per machine; before each up the registry also
requires MemAvailable ≥ 8 GiB, ≥ 20 GiB free on / and a 1-minute load < 0.75 × CPUs.
Team sync
sync=off(default, one developer): board commits stay local untilkanban publishpusheskanbanandmain.KANBAN_SYNC=on(teams): every write pulls first and pushes after; a claim is won only by the push that lands (the loser exits 8). Rejected pushes rebase and retry 3×, then exit 9; nothing is ever force-pushed.- Each machine runs
vendor/bin/kanban attachonce (SessionStart does it whendocs/kanbanis missing); it also configures the merge driver, without which git would silently text-merge board files. - Only one orchestrating session per machine holds the lease (15 min idle expiry,
kanban lease --takeover).
Configuration
php artisan vendor:publish --tag=kanban-config → config/kanban.php: ui.* (the local /kanban UI, local env and
main checkout only), agents.worker|evaluator.model|effort (written into the agent files), main_branch, remote,
sync, worktrees.*, stack.* (compose file, project pattern, port pool, env block, caps), finish.after (commands
run on main after a merge), gates.report (commands a worker's branch must pass, e.g. npm run check; context
prints them for both agents), githooks.reject_co_authored (default true).
Gotchas
Surprises met in real runs are in
references/gotchas.md as symptom → cause → fix.
License
MIT