skip to content
$registry / workers

kanban

v0.1.22

File-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.

worker
iiiverified
247 installs64 in 7d14 today
install
$iii trigger compose::add worker=kanban@0.1.22
binarylicense: Apache-2.0agentsboardconsole-uikanbantickets
  • macOS: arm64 · x64
  • Linux: arm64 · armv7 · x64
  • Windows: arm64 · x64

exact versions are immutable; binary and bundle artifacts are digest-pinned.

skill doc

ticket-worker

how-to
skills/tickets/ticket-worker.md

Working a ticket you own

You were dispatched onto a ticket by someone who cannot talk to you: this engine has no harness::send (confirm with engine::functions::list { "prefix": "harness::" }). Every instruction you get arrived in your task or in a ticket comment, and every answer you give has to land as a ticket comment. Your chat is not a channel.

Open these first

  • kanban::config::info {} — the real column ids (never assume in_review / done), which of them is done_status, the key prefix, and the board file path.
  • engine::functions::info { "function_ids": ["kanban::ticket::get", "kanban::ticket::update", "kanban::comment::create", "kanban::comment::list", "kanban::ticket::move", "engine::register_trigger", "harness::triggers::list", "harness::triggers::unregister"] } — the exact schemas behind every call below.
  • directory::skills::get { "id": "kanban/tickets/kanban-tickets" } — the deep reference: comment threads, the activity timeline, kanban:change, the soft-delete rules. Fetch it when you need any of those; skip it otherwise.
  • kanban::ticket::get { "id": "KAN-8" } — read the ticket you were dispatched onto, whole, its comments and activity included, before you change anything. The key (KAN-8) and the uuid both work, case-insensitively. A subtask also carries a parent (a brief with key, title and status); a feature ticket carries children and a progress count.

The loop

Your profile id is the string in your frontmatter (backend-engineer, frontend-engineer). Use it verbatim as actor on every write and author on every comment — it is also the string the wake filters key on, so a typo silently breaks the loop.

  1. Check the parent. If the ticket has a parent whose status is the done_status column, the feature it belongs to already closed: do not claim. Comment on your own ticket saying the parent is done and that you stopped, and stop. The dispatcher decides whether the ticket is cut or the parent reopens.
  2. Claim. kanban::ticket::update { "id": "KAN-8", "assignee": "", "status": "in_progress", "actor": "" }.
  3. Arm your wake and its teardown — before you report. See The pair below. Reporting first is the failure this step exists to prevent: the reviewer's answer arrives after your turn ended and wakes nobody.
  4. Work, then verify it the way your own profile requires.
  5. Report on the ticket — kanban::comment::create { "ticket_id": "KAN-8", "author": "", "body": "..." } — with the calls you made and what they returned. A summary in your chat is invisible to the reviewer. Report on your own ticket, never on the parent: the parent's thread belongs to the dispatcher, and your hand-off is the ticket you own.
  6. Hand off — kanban::ticket::move to in_review when someone else must check it, and to done only when the ticket's own Verify: targets passed and you observed them. Moving your ticket never moves the parent; do not touch the parent's status, project or release.
  7. On a wake (a review comment, a rejection): re-read the ticket with kanban::ticket::get first — a late or re-armed fire can deliver a comment for work that already closed. Answer on the ticket, then re-arm the pair if the work is still open.

The pair: never arm a wake without its done-watch

Two bindings per ticket, registered together. The first is how you hear back; the second is what removes the first when the ticket lands.

engine::register_trigger {
  "trigger_type": "kanban:comment",
  "config": { "ticket_id": "KAN-8", "exclude_author": "backend-engineer", "ticket": "summary" },
  "once": false,
  "label": "KAN-8-wake",
  "lifecycle": { "max_fires": 20, "expires_in_ms": 3600000 }
}
engine::register_trigger {
  "trigger_type": "kanban:change",
  "config": { "ticket_id": "KAN-8", "events": ["ticket.moved"], "ticket": "summary" },
  "once": false,
  "label": "KAN-8-teardown",
  "lifecycle": { "max_fires": 50, "expires_in_ms": 86400000 }
}
  • exclude_author must equal your author string character for character, or your own comment re-enters your handler.
  • "ticket": "summary" keeps the wake small: you re-read the ticket with kanban::ticket::get on every wake anyway, so the full thread in the payload is paid twice.
  • Bind on your own ticket (ticket_id), not on the parent's parent_id: a parent_id binding would wake you for every sibling's comment and move.
  • Label every binding -. The reap below is mechanical only if you can recognise your own bindings by label.
  • Bound every binding's life. A session that dies between arming and done leaves nothing to reap but the deadline.

The teardown handler

kanban:change fires on your own moves too, so the first act is always a status read:

kanban::ticket::get { "id": "KAN-8" }

If status is not done, stop — you were woken by a move made for another reason. If it is done, reap:

harness::triggers::list {}

Unregister every binding whose label starts with KAN-8- — the wake, the cron backstop, any re-armed duplicate — then the teardown binding itself last, because it has to stay alive while you reap:

harness::triggers::unregister { "subscription_id": "sub_..." }

Finish by listing again and confirming none of your KAN-8-* labels remain. That read-back is the proof; the unregister response alone is not.

Why you reap your own. engine::unregister_trigger removes "a trigger subscription owned by the current harness session"; a foreign session_id is not honoured (a listing call carrying someone else's session id returns your own bindings), and agent wakes do not appear in engine::registered-triggers::list at all. No dispatcher, reviewer, or reaper can clean up after you. Yours are yours to retire, and the done event is the moment.

The backstop: a wake is not a guarantee

The kanban worker delivers only to bindings in its own in-process subscriber map, and that map dies with the worker process. A binding it no longer holds never fires and reports nothing at all — silence indistinguishable from "no news". So never let the wake be your only way back.

engine::register_trigger {
  "trigger_type": "cron",
  "config": { "expression": "0 * * * *" },
  "once": false,
  "label": "KAN-8-check-in"
}

On each fire, read what you missed and move the watermark forward:

kanban::comment::list {
  "ticket_id": "KAN-8",
  "exclude_author": "<your id>",
  "since": "<the last comment timestamp you saw>"
}

Traps

  • The reviewer rejects and nothing happens. Your turn had already ended and no binding was armed on the ticket. Cause: you reported before arming the pair. Fix: arm first, report second.
  • Your own comment wakes you, forever. The exclude_author string is not byte-identical to your author. Fix: copy the profile id out of your frontmatter; do not retype it from memory.
  • A binding fires for a ticket that is already closed. A late, re-armed, or duplicated fire. Fix: re-read the ticket and no-op unless it is still open and assigned to you.
  • A ticket is done and bindings are still armed. Cause: no done-watch was armed, or the reap stopped after the first unregister. Fix: arm the pair together, unregister every -* label with the teardown binding last, and read the list back.
  • You cannot tell your bindings apart while reaping. Cause: labels like wake or check-in with no ticket key. Fix: -, always.
  • The ticket sits in in_progress and nobody answers. Cause: your report was a chat message, or author was left at its user default so the reviewer's filters did not see it. Fix: comment on the ticket, and pass author every time.
  • You claimed a subtask of a closed feature. Symptom: the parent is done, and your work lands on a ticket nobody is tracking. Cause: step 1 skipped. Fix: read parent.status before the claim; if it is the done column, comment on your ticket and stop.
  • You reported on the parent. Symptom: the dispatcher's wake on the parent fires with your report mixed into the feature's thread, and your own ticket's reviewer sees nothing. Fix: ticket_id is always your ticket.
  • You reached for a function that does not exist. There is no harness::send; the wire is the ticket. Fix: engine::functions::list / directory::search_functions before naming an id.

Checklist

  • kanban::config::info read; column ids taken from the board, not assumed.
  • Ticket read whole — description, comments, activity — before any write.
  • If the ticket has a parent, its status was read; a parent in the done column means comment on your own ticket and stop, no claim.
  • Claimed with assignee, status, and actor all set to your profile id.
  • kanban:comment wake armed with exclude_author = your author, labelled -wake, bounded by lifecycle.
  • kanban:change done-watch armed on the same ticket, labelled -teardown, before the first report.
  • A cron check-in reading kanban::comment::list with the same filters, so the loop cannot fall silent.
  • Report posted as a comment on your own ticket (never the parent) naming what you ran and what it returned.
  • Ticket moved to the lane the evidence earned.
  • On done: every -* binding unregistered, teardown binding last, then harness::triggers::list re-read to confirm none remain.