kanban
v0.1.16File-backed kanban board — tickets, threaded comments, agent assignment, the kanban:change and kanban:comment trigger types, and an injectable console board with agent profiles for planning and review.
Install
iii trigger compose::add worker=kanban@0.1.16Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
skill doc
ticket-orchestration
how-toOrchestrating a feature across agent profiles
You are the one who splits a feature across other profiles. The kanban board is the only wire between you and them. Everything below exists to keep that wire honest.
Open these before you dispatch
- The dispatch contract:
engine::functions::info { function_ids: ["harness::spawn"] }. Readtask,agent,options.functions, and the return shape before spawning anything. - The roster you dispatch into:
directory::agents::get { id: ". The", raw: true } skills:andfunctions:lists are what that child actually starts with — and what it therefore does not know. - The board:
kanban::board::get { "statuses": [— once per planning pass, not per wake — and], "compact": true } kanban::config::info {}for the real column ids and key prefix. Add"status": "done"withupdated_sinceto see what landed recently. - The mechanics: the
kanban-ticketsskill — keys vs uuids,actor/author, the comment filters, review-vs-done, never delete. The child's half of the same loop is theticket-workerskill, which the engineer profiles preload.
The ticket is the only channel
engine::functions::list { prefix: "harness::" } shows no harness::send on this
engine. Combined with the spawn contract — "the child's outcome reaches you only through
whatever destination its task names" — that leaves exactly one wire:
- The task text is the child's whole brief. It cannot infer a path, a function id, a convention, or who else is working on the feature. Name them literally or it guesses.
- The destination must be a ticket. A comment on the ticket is how the child reports; a lane move is what it believes it achieved. Neither is proof — see Your gate.
- Your answer goes back on the same ticket. A decision you keep in your own session never reaches the engineer.
- The return leg has to exist. The child only hears your answer if it armed a wake on
the ticket with
authorandexclude_authorset to its own profile id. Nothing you do in your session can arm that for it — name the duty in the task.
Split on the seam, not on the layer
The failure this role exists to prevent is a ticket pair that both pass and still do not work together. Prevent it structurally:
- Contract first. Exactly one ticket owns each new function id's schema — the id, the
request fields, the response fields — and it must be
donebefore the ticket that calls it starts. Give the consumer ticketDepends on:and do not dispatch it early. - One owner per ticket. A ticket with two assignees has no owner. Work needing both is two tickets.
- Split by outcome. Each ticket must be verifiable alone, by someone who did not write it.
- Say what is out of scope. The cheapest way to stop a child inventing work.
Symptom of skipping the contract ticket: the frontend registers game::move with { cell }
while the backend registered game::play with { row, col }. Two green tickets, one broken
feature.
The description template
Put this in every ticket you create — it is the child's contract as much as yours:
Deliverable: <the one thing this ticket produces>
Owner: <profile id>
Depends on: <ticket key + the function ids that must already resolve>
Acceptance:
1. <observable statement>
Verify: <a command, a url, or a screen — something a stranger can run>
2. ...
Out of scope: <what a reader would reasonably assume is included and is not>A criterion with no Verify: line is not a criterion, it is a wish — and it comes back to
you as a caveat you cannot adjudicate.
kanban::ticket::create takes title plus any of description, assignee, priority,
labels, status, and actor. Create into the default column and let the child move
itself, so its claim is visible on the activity timeline.
Dispatch
Spawn after the ticket exists — the task's first instruction is to read it.
harness::spawn {
"agent": "backend-engineer",
"session_id": "kan8-backend",
"display": { "name": "Backend · KAN-8", "icon": "terminal", "color": "blue" },
"options": { "functions": { "allow": ["kanban::ticket::get", "kanban::ticket::update", "kanban::ticket::move", "kanban::comment::create", "kanban::comment::list", "kanban::config::info", "engine::register_trigger", "harness::triggers::list", "harness::triggers::unregister", "coder::read-file", "coder::create-file", "coder::update-file", "coder::search", "coder::tree", "shell::exec"] } },
"task": "You own ticket KAN-8 on the kanban board; work it with the ticket-worker skill. Read it with kanban::ticket::get { \"id\": \"KAN-8\" }, then claim it: kanban::ticket::update { \"id\": \"KAN-8\", \"assignee\": \"backend-engineer\", \"status\": \"in_progress\", \"actor\": \"backend-engineer\" }. Before you report, arm the pair the skill describes: engine::register_trigger on kanban:comment with config { \"ticket_id\": \"KAN-8\", \"exclude_author\": \"backend-engineer\", \"ticket\": \"summary\" }, plus the kanban:change done-watch labelled KAN-8-teardown — and reap every KAN-8-* binding when the ticket reaches done. Do the work in <path>. The function ids to register are ... When finished, report with kanban::comment::create { \"ticket_id\": \"KAN-8\", \"author\": \"backend-engineer\", \"body\": \"<exactly what you ran and what it returned>\" } and then kanban::ticket::move { \"id\": \"KAN-8\", \"status\": \"in_review\", \"actor\": \"backend-engineer\" }. Do not delete the ticket."
}agentis a profile id fromdirectory::agents::list, never a display name.- One fresh
session_idper ticket: the console tree stays legible, and the child cannot inherit another ticket's context. - The task names the ticket key, the exact claim/report/hand-off calls, the arm-the-pair duty, and the stop condition. Assume the child arrives knowing nothing but its own profile.
- The engineer profiles preload the
ticket-workerskill and the ticket loop's function ids, so the brief can name the ticket, the owner, and the stop condition and let the skill carry the call order. Name the ticket key and the owner literally anyway — the child cannot infer which ticket it owns. - Preloading is not permission.
options.functionsis the fail-closed dispatch policy, intersected with your own and never escalating; absent, every call is denied. Passoptions.functions.allowwith the ids the child needs, spelled out one per entry — no wildcard: the sixkanban::calls above,engine::register_trigger,harness::triggers::list,harness::triggers::unregister, and its own toolchain — or it answers "I cannot call any function."engine::register_triggeris the entry you must never drop: without it the child cannot arm itskanban:commentwake, and every answer you post on the ticket reaches nobody. harness::spawnreturns{ child_session_id, child_turn_id }immediately. That means the child started. It never means the child finished.
Stay reachable
Arm the wake before the turn that dispatches, one binding per active ticket:
engine::register_trigger {
"trigger_type": "kanban:comment",
"config": { "ticket_id": "KAN-8", "exclude_author": "tech-lead", "ticket": "summary" },
"once": false,
"label": "KAN-8-wake",
"lifecycle": { "max_fires": 20, "expires_in_ms": 3600000 }
}exclude_authormust equal the id you pass asauthoron your own comments, character for character. One typo and your own reply re-enters your handler.- Leaving
exclude_authoroff wakes you on every comment, your own included — only safe when your handler never comments back on that ticket. - Bound the life. A bare
once: falsewith nolifecycleis the binding that outlives its ticket and wakes a session with nothing to do. - Label every binding
so the reap is mechanical, not a judgement call.- - Pass
"ticket": "summary"on every binding you arm: a wake then costs a board card, not the whole thread, and you re-read the ticket before acting anyway. - Pair it with a
croncheck-in readingkanban::comment::list { ticket_id, exclude_author, since }. A wake the kanban worker never emits reports nothing at all — silence looks exactly like "no news". The cron sweep goes through the board file and cannot fall silent with the worker's emit path. kanban:commentfires per ticket, so N tickets need N bindings.harness::triggers::listaudits what is actually armed;harness::triggers::unregisterretires a ticket's wiring once it lands.- Arm a done-watch with every wake —
kanban:changewithconfig: { "ticket_id": ", labelled", "events": ["ticket.moved"], "ticket": "summary" } . On each fire read the ticket with-teardown kanban::ticket::get; whenstatusisdone, unregister everybinding on that ticket, the done-watch last so it stays alive while you reap. Nobody can reap for you:-* engine::unregister_triggerremoves only bindings owned by the calling session, a foreignsession_idis not honoured, and agent wakes do not appear inengine::registered-triggers::listat all. Your engineers reap their own on the same event; the task must tell them to. kanban:changeis board-wide and filters by ticket and event only — no author filtering. Never use it where you need to avoid waking on your own comments.
When a wake arrives: read the ticket, decide, answer on the ticket. Re-read the ticket first and no-op unless it is still open — a re-armed or late-firing binding can deliver a comment for work that is already closed.
Fan-in: knowing the children actually stopped
harness::metrics { "root_session_id": "<your session id>" }complete is true only once every session in your durable tree reached a terminal turn.
That is the "all my children are done" signal — not a guess, not a timeout.
harness::session-tree { root_session_id }— the shape of the run, root and descendants.harness::status { session_id }— one child's current turn (lean;verbose: truefor the full report and untruncated result).
Traps
- The child reports success and the feature does not work. Each ticket was checked against its own contract, not against the seam. Someone must exercise caller and callee together; that check belongs on a ticket, not in a summary.
- A spawned engineer answers "I cannot call any function." The dispatch policy on the spawn
was fail-closed. Pass
options.functionsexplicitly, with the ids it needs inallow. - The child reports and your answer wakes nobody. The child never armed a wake on the ticket, so your review comment lands on a session whose turn already ended. Cause: the task named the claim and the hand-off but not the arm-the-pair duty. Fix: say it in the task, and check the ticket's reply flow on a first small ticket before trusting the pattern.
doneand the wakes keep firing. The ticket landed and the bindings armed on it were never reaped, so sessions get woken with nothing to do. Cause: a wake armed without its done-watch. Fix: arm the pair together, and label every binding.- - You dispatched against a stale dependency. The upstream function id or schema changed while the consumer ticket sat in the queue. Re-read the upstream ticket immediately before dispatching the consumer, not when you wrote the plan.
- Two tickets editing one file. Parallel children have no merge protocol here. If the split
has both touching
package.json, the route tree, or one shared module, it is one ticket. - You rewrote another profile's report. Comment as yourself; never edit what a child claimed.
- A child spawned with a tiny
options.max_turnsstrands mid-task and reports nothing. Omit it unless you have a reason.
Checklist
- Every ticket names one owner, one deliverable, its dependency by key, and criteria with
literal
Verify:targets. - The schema-owning ticket is
donebefore any consumer ticket is dispatched. - Each child's task names the ticket key, the claim/report/hand-off calls, the arm-the-pair duty, and the stop condition — with no reference to anything only you can see.
- The spawn carries
options.functions.allowwith the ids the child actually needs, named explicitly. - A
kanban:commentbinding withexclude_authorset to your own id is armed per active ticket, bounded bylifecycle, labelled, with a-wake cronbackstop. - Every wake has its
done-watch, and every binding is unregistered once its ticket is-teardown done. -
harness::metrics { root_session_id }reportscomplete: truebefore you claim the fan-out finished. - The seam was exercised end to end — caller and callee in one run — and the evidence is in a ticket comment, not in your summary.