---
{
  "n": 40,
  "title": "Kanban board - the plan as it was written",
  "abstract": "Plan 10 as the Opus planning agent wrote it: the goal, six phases, eighteen to-dos and four questions. The board was built from it and shipped.",
  "refs": [
    "doc:37"
  ],
  "seen": [
    "agent",
    "user"
  ],
  "data": {
    "kept": false,
    "revisions": 1,
    "open_until": 1790095187.221076
  },
  "created": 1790028291.78262,
  "updated": 1790093387.221247,
  "deleted": 0.0,
  "completed": 0.0,
  "outcome": "",
  "type": "doc"
}
---
Goal: The viewer has a Board page that shows every open to-do of the environment in five lanes derived from its state, cards move between lanes by drag and drop through one journal command that either changes the row or refuses in words, agents and their work are visible on the cards and above the lanes, the board updates live, and the same board is readable and movable from the CLI as journal todo board and journal todo shift

Scope. Only the Kanban board from doc 30. Named restricted agents, orchestrator mode, protocols, plans as cards and per-agent skills are out of scope. The board is a visual layer over the to-dos that already exist (Jesse in doc 30: a Kanban is just a list of to-dos). It adds no resource type and stores nothing of its own.

Columns. A column is a lane computed from the state of a to-do, never configured and never stored. There are five lanes, in this order: To do (key todo), Held (key held), Doing (key doing), Needs you (key asked), Done (key done). A to-do sits in exactly one lane, decided by the first rule that matches, in this precedence. done: the row is completed, not struck, and completed within the done_days setting (struck rows and older done rows are left off the board). asked: an open question links to the row (its refs contain todo:n). doing: the row status is started and the work it names is not completed (parked work stays in Doing with a parked chip). held: the row is blocked, or waits on a row or plan that is still open (Todos.waits), or sits in a plan whose status is not active or whose current phase does not hold it. todo: otherwise. The global hold an active plan puts on unplanned rows below critical (features/plans/progress.held) does NOT move cards to Held. It is shown once as a banner above the lanes (Plan n is active, rows outside it wait unless critical), because otherwise every card would sit in Held while any plan runs.

Cards. A card is one to-do. Plans, questions, work and agents are not cards. They appear as chips on cards. A card holds n, title, priority, lane, reason (the words for why it is held: blocked and its why, waits on todo 12, plan 3 phase 2), plan (n, title, phase number) when a plan places it, assigned (the agent id from todo assign), worker (the open work n, the agent that holds it, whether that agent is a subagent, whether the work is parked), question (the open question n), reported (true when a subagent reported and the row is not closed), targets (the lanes a shift from here would be accepted into, computed by the same rules the shift uses, so the viewer never repeats them), updated and completed. Order in every lane is priority high to low, then number low to high (the order Todos._ordered already uses). Done is ordered by completed, newest first.

Backend. A new feature features/kanban with name kanban, title Kanban board, on by default. details.py holds KanbanDetails (name, title, abstract, help, and one setting done_days, default 7, titled Show done cards for, unit days). lanes.py holds the lane rules as small objects: a Lane dataclass (key, title), LANES in order, a Card dataclass, and lane_of(record, todo) with reason_of beside it. board.py holds a Board dataclass (lanes with their cards, agents, plan_hold) built from the todos, works, questions, plans and agents controllers through context.journal, with shaped() returning the dict the viewer reads. That dict also carries out, a plain text rendering, so the CLI prints the board as lanes of lines (commands/cli.py prints got[out] for a dict). commands.py holds two Command parts registered on the todo type in feature.py with journal.commands.add. ShowBoard, name board, run(context, todos, plan: int = 0, agent: str = ""): journal todo board [--plan n] [--agent id] prints the board and POST /api/<env>/todo/board returns it. ShiftCard, name shift, run(context, todos, n: int, lane: str, why: str = "", how: str = ""): journal todo shift 12 held --why "..." moves a card and POST /api/<env>/todo/12/shift does the same. The command is shift, not move, because every controller already has move(n, env) for moving a row to another environment, and a command of the same name would shadow it. board is added to the READS set in commands/cli.py so a lent subagent may read it. When the feature is off both commands are refused in words by the Commands registry.

Moving a card. Every drag ends in one call to ShiftCard, which calls the existing Todos methods on the controller it was handed, so the actor (user from the viewer, agent from the CLI), every ActionInterceptor and every event stay exactly as they are today. The board emits no event of its own. Allowed: To do to Held calls todos.block(n, why), why required. Held to To do calls todos.unblock(n) when the row is blocked, and is refused when the row waits on an open row (todo 14 waits on todo 12: journal todo after 14 12 --off drops the wait) or is held by a plan (plan 3 holds todo 14 until its phase 2). To do or Held to Done calls todos.complete(n, how), how defaulting to closed on the board. Done to To do calls todos.reopen(n, why), why required. Refused, each with a sentence that names the command that would do it: anything into Doing (an agent starts a to-do with journal todo start, hand it to one with Assign on the card), anything into Needs you (a question puts a card there), anything out of Doing (work 40 is open on todo 14, the agent ends or parks it), anything out of Needs you (question 60 waits on you, answer it and the card moves by itself), Held to Done while the row waits on something open, and an unknown lane name. A shift into the lane the card is already in returns the card unchanged. The resulting events are the existing todo.updated, todo.completed and todo.reopened.

Agents on the board. The board payload carries the agents of the environment that are not stopped (agent rows): name (the subagent id in data.agent, else the session), status (idle, busy, compacting), parent, the work they hold and its to-do, and their subagent count. The viewer shows them as a strip above the lanes. Clicking one filters the board to cards assigned to it or worked by it, clicking again clears the filter, and a second button on the chip opens its agent panel with peek. On a card the assigned agent and the working agent show as chips (one chip when they are the same), a subagent is marked as such, a reported row shows a Reported chip, and a card with an open question shows an exclamation badge. The card menu offers Assign to, listing the strip agents, which calls the existing todo assign action (api.act todo n assign with to), and Unassign (off). Named restricted agent profiles do not exist yet, so the board shows whatever id the row carries.

Filters. Server side: plan (only the rows a plan places, with phase chips) and agent. Client side: a text filter on title and number, and a Show done switch. The lens (plan, agent, done switch) is kept in the store and remembered across reloads under journal.board.lens.

Live updates. The page owns one poll, usePoll with key board, every 5 seconds, asking api.board(lens). It also re-asks at once when an event of type todo, work, question, plan or agent arrives in store.events (a watch on the newest event id, filtered by type), and after its own shift call returns. A shift is not optimistic: the card shows a moving state until the reply, then the fresh board replaces it. A refusal leaves the card where it was and shows the refusal text in the bar for a few seconds.

Loading and empty states. Before the first reply: five skeleton lanes with three blank cards each (the existing .blank skeleton styles). With no cards at all: Nothing is on the list, with a New to-do button that opens the existing NewResource for todo. An empty lane shows No cards and still takes drops. While a card is dragged, lanes not in its targets are dimmed and refuse the drop in the browser before any call. When the feature is off the sidebar hides the Board item and the page shows The Kanban board is off with a link to Settings.

Viewer. Route #/<env>/board, added to the page list in web/src/App.vue with a board slot rendering web/src/pages/BoardPage.vue. The Sidebar shows Board right under Home in the environment group when store.settings.features.kanban is on, with the open to-do count. api/client.js names two endpoints: board(lens) posting to todo/board, and shift(n, lane, words) posting to todo/n/shift. store.js gains board: lanes, agents, planHold, loaded and lens. Components in a new folder web/src/board: Lane.vue (one lane, its drop zone and empty state), Card.vue (number, priority icon, title, chips, badge, menu), AgentStrip.vue (the agents above the lanes), ShiftPrompt.vue (the small dialog asking why or how, built on kit/Dialog.vue), CardMenu.vue (Move to, Assign to, Open). One composable web/src/composables/cardDrag.js holds what is being dragged and whether a lane takes it, using native HTML5 drag and drop (no new dependency). The Move to menu gives the same moves without a pointer. Clicking a card opens the existing to-do inspector stacked over the board with peek(todo, n), a plan chip peeks the plan. Formatting follows the repo: 4-space indent, expanded CSS, no comments.

Tests. One features/kanban/test.py under 150 lines, because commands added as parts are not reached by tests/test_every_action.py, which loops over controller methods only. It covers lane_of for each of the five lanes and the precedence between them, each allowed shift and the row change it makes, each refusal and that its words name a command, that a shift into the same lane changes nothing, and that both commands are refused when the feature is off. The viewer is checked by npm run build in web, journal check sweep (feature_shape, test_shape, one_client, funnels, prose_names), and a look in the browser.

Not decided here, filed as questions on this plan: whether a drop on Doing should hand the card to an agent instead of refusing, whether plans get their own board, whether reordering in a lane sets priority, and whether the board replaces the Home to-do rail.

## Phase: Lanes and cards read from the record
Complete when: journal todo board prints every open to-do in its lane with its chips

The kanban feature folder, the lane rules as objects, the Board built from to-dos, work, questions, plans and agents, and the board command with its plan and agent filters

**To-do 665, Kanban feature folder with details and settings.** Create features/kanban with __init__.py, details.py and feature.py, following features/designs and features/plans. details.py: class KanbanDetails(FeatureDetails) with name kanban, title Kanban board, abstract (The to-dos of an environment as lanes of cards, moved by drag or by journal todo shift), help (what the five lanes mean, journal todo board [--plan n] [--agent id], journal todo shift <n> <lane> [--why] [--how]), and settings = [Setting(name=done_days, default=7, title=Show done cards for, abstract=A done card stays on the board this many days after it closed, unit=days)]. feature.py: class KanbanFeature(Feature) with details = KanbanDetails and register(journal) that only calls journal.commands.add(todo, ShowBoard()) and journal.commands.add(todo, ShiftCard()) (the classes land in the next rows, so register only ShowBoard until ShiftCard exists). No comments, no docstrings, every class attribute annotated. Check: journal feature all lists kanban as on, journal check sweep passes feature_shape and prose_names.

**To-do 666, Lane rules as objects in features/kanban/lanes.py.** Write features/kanban/lanes.py. A frozen dataclass Lane(key: str, title: str) and LANES = (todo To do, held Held, doing Doing, asked Needs you, done Done) in that order. A dataclass Card with n, title, priority, lane, reason, plan (a small PlanPlace dataclass: n, title, phase), assigned, worker (a Worker dataclass: work, agent, subagent, parked), question, reported, targets (list of lane keys, filled by the shift rules in a later row, empty for now), updated, completed. lane_of(record, todo) -> Lane decides by the first matching rule in this precedence: done (completed, not struck, completed within done_days), asked (an open question whose refs contain the row ref, found with Questions.linked_to), doing (status started and the work named in todo.work is not completed), held (blocked, or Todos.waits(row) is not empty, or the row sits in a plan whose status is not active or whose current phase does not hold it, using features.plans.progress.current_phase), todo otherwise. Do NOT use progress.held, which also holds every unplanned row while a plan runs. reason_of(record, todo) returns the words for Held: blocked and its why, waits on todo 12, or plan 3 phase 2. Reach controllers through context.journal where a context is at hand, else by class from controllers.types. Keep each function short. Check: exercised by features/kanban/test.py in phase 2, and by journal todo board output in the next row.

**To-do 667, Board built from to-dos, work, questions, plans and agents.** Write features/kanban/board.py. A dataclass Board with lanes (list of LaneCards: lane and its cards), agents (list of AgentChip: n, name, session, status, parent, work, todo, subagents) and plan_hold (the active plan n and title when an active plan holds unplanned rows, else None). A function built(journal, done_days, plan=0, agent="") -> Board: open to-dos plus to-dos completed within done_days, each made into a Card via lanes.py, filtered to the rows a plan places when plan is given, and to rows assigned to or worked by agent when agent is given; ordered priority high to low then n (Todos._ordered), Done newest completed first. Agents: agent rows of the environment whose status is not stopped, name from data.agent else the session, work from the open work row they hold. Board.shaped() returns a dict with lanes, agents, plan_hold and out, where out is a plain text rendering: one heading per lane with its count, then one line per card (#n title [reason] @agent). Objects over dicts everywhere except the final shaped(). Check: journal todo board in a fresh environment with a blocked row, a started row, a row with a question and a done row puts each in its lane.

**To-do 668, journal todo board command.** Write features/kanban/commands.py with class ShowBoard(Command), name = board, run(self, context, todos, plan: int = 0, agent: str = "") returning board.built(context.journal, context.settings.done_days, plan, agent).shaped(). Register it in features/kanban/feature.py with journal.commands.add(todo, ShowBoard()). Add board to READS in commands/cli.py so a lent subagent may read it. This makes journal todo board [--plan n] [--agent id] print the out text and POST /api/<env>/todo/board return the whole dict (commands/http.py post_action_bare, commands/dispatch.py represented passes a dict through). Check: journal todo board prints lanes, journal todo board --plan 10 prints only plan rows with phase numbers, curl -X POST localhost:8430/api/main/todo/board returns JSON with lanes and agents, and journal todo move still moves a row to another environment (nothing shadowed).

## Phase: Moving a card by command
Complete when: journal todo shift moves or refuses in words, and the feature test passes (checkpoint)

ShiftCard with its allowed moves and refusals, the targets on each card, and features/kanban/test.py. The user reads the board and tries shift in the CLI before any viewer work

**To-do 669, journal todo shift command with its moves and refusals.** Add class ShiftCard(Command), name = shift, run(self, context, todos, n: int, lane: str, why: str = "", how: str = "") to features/kanban/commands.py and register it on todo in feature.py. It is named shift because every controller already has move(n, env). Put the rules in features/kanban/shifts.py as one small object per allowed move so ShiftCard only looks up and runs. Allowed: todo to held calls todos.block(n, why) and refuses without a why. held to todo calls todos.unblock(n) when blocked, and refuses when the row waits on an open row (todo 14 waits on todo 12: journal todo after 14 12 --off drops the wait) or a plan holds it (plan 3 holds todo 14 until its phase 2). todo or held to done calls todos.complete(n, how or closed on the board), refused while the row waits on something open. done to todo calls todos.reopen(n, why) and refuses without a why. Refused with words that name the command that would do it: into doing (an agent starts a to-do with journal todo start, hand it to one with journal todo assign), into asked (a question puts a card there), out of doing (work 40 is open on todo 14, the agent ends or parks it), out of asked (question 60 waits on you, answer it and the card moves by itself), unknown lane (a lane is one of todo, held, doing, asked, done). Same lane returns the card unchanged with no write. Always call the Todos controller that was handed in, so actor, interceptors and events (todo.updated, todo.completed, todo.reopened) stay as they are. Return the fresh Card shaped. Check: each move from the CLI and each refusal text, then POST /api/main/todo/<n>/shift with lane and why.

**To-do 670, Targets on each card from the shift rules.** Fill Card.targets from the same rule objects in features/kanban/shifts.py: for each lane, whether a shift from this card to it would be accepted, ignoring a missing why or how, which the viewer asks for. The viewer only reads targets and never repeats the rules. Check through the HTTP dict of POST /api/main/todo/board: a plain open row has targets held and done, a blocked row has todo and done, a row waiting on another open row has none, a doing row has none, a done row has todo.

**To-do 671, features/kanban/test.py for lanes, shifts and the feature switch.** One test file under 150 lines, justified because feature Command parts are not reached by tests/test_every_action.py, which loops over controller methods only. Follow features/plans/test.py and tests/kit.py for making a fresh record. Cover: lane_of for each of the five lanes and the precedence (a started row with an open question is asked, a done row older than done_days is off the board, a row outside an active plan stays in todo); each allowed shift and the change it makes on the row; each refusal raises Refused and its words name a journal command; a shift into the same lane writes nothing; both commands refused when the kanban feature is switched off. Check: pytest features/kanban/test.py passes, journal check sweep passes test_shape and funnels.

## Phase: The Board page in the viewer
Complete when: The sidebar Board item opens five lanes of cards, and a card opens its to-do

Client endpoints, store state, the route and sidebar item, BoardPage with Lane and Card, the skeleton, empty and feature-off states

**To-do 672, Board endpoints in the client and board state in the store.** web/src/api/client.js: add board({plan, agent}) returning this.command(todo, board, {plan: plan || 0, agent: agent || ""}) and shift(n, lane, {why, how}) returning this.act(todo, n, shift, {lane, why, how}). These are the only two new endpoints and every call goes through them (scripts/checks/one_client.py). web/src/state/store.js: add board: {lanes: [], agents: [], planHold: null, loaded: false, lens: remembered(journal.board.lens, {plan: 0, agent: "", done: true})} and kept(journal.board.lens, () => store.board.lens). The store holds state only, no fetching. 4-space indent. Check: npm run build in web passes, journal check sweep passes one_client.

**To-do 673, Board route and sidebar item.** web/src/App.vue: add board to the list of named pages and a <template #board><BoardPage /></template> slot, importing pages/BoardPage.vue. web/src/layout/Sidebar.vue: in the environment group, right under the Home item, an item linking #/<env>/board with a columns-like icon from kit/Icon.vue (add the glyph there if none fits), the label Board and the open to-do count (counted(todo)), shown only when store.settings.features.kanban is not false, marked on when route.page is board. Check: the item appears, highlights on the page, and disappears when journal feature switch kanban turns it off.

**To-do 674, BoardPage with Lane and Card components.** web/src/pages/BoardPage.vue owns the page: a bar (title, the filters placeholder, the refusal line) and a horizontal row of five lanes, scrolling sideways when narrow. It fetches with usePoll(board, () => api.board(store.board.lens), 5000, take) where take writes lanes, agents, planHold and loaded into store.board. New folder web/src/board: Lane.vue (title, count, the cards, No cards when empty) and Card.vue (#n, PriorityIcon from kit, title, a reason line for Held, a plan chip Plan n phase p that peeks the plan, and the card itself peeks the to-do with peek(todo, n) from route.js so the to-do inspector stacks over the board). Show the plan_hold banner above the lanes when present. Follow pages/Index.vue and pages/RailTodos.vue for markup and class names, 4-space indent, expanded CSS, tokens from tokens.css. Check: the page shows the same lanes as journal todo board, a card click opens the to-do.

**To-do 675, Board loading, empty and feature-off states.** In BoardPage.vue and web/src/board/Lane.vue: before store.board.loaded, five skeleton lanes with three blank cards each using the existing .skeleton and .blank styles (see resource/Reader.vue). With no cards in any lane: an empty block with Nothing is on the list and a New to-do button opening resource/NewResource.vue for todo. An empty lane shows No cards. When store.settings.features.kanban is false: The Kanban board is off, with a link to #/<env>/settings, and no poll runs. Check: each state seen in the browser, with an empty environment, a fresh load and the feature switched off.

## Phase: Drag and drop and card actions
Complete when: A drag or the Move to menu changes the row, and a refusal explains itself

The cardDrag composable, the ShiftPrompt dialog for why and how, the refusal line in the bar, and the card menu with Move to, Assign to and Open

**To-do 676, Drag and drop between lanes with the cardDrag composable.** web/src/composables/cardDrag.js: one composable holding the card being dragged (n, lane, targets) and takes(laneKey) that says whether a lane accepts it, using native HTML5 drag and drop (draggable, dragstart, dragover, drop, dragend), no new dependency. Card.vue sets itself draggable and starts the drag. Lane.vue highlights while a card it takes is over it, dims when it does not take it, and on drop calls the shift flow in BoardPage. The shift is not optimistic: the card shows a moving state until api.shift replies, then BoardPage re-asks the board. Check: drag a To do card to Held and to Done, drag a Done card back to To do, and confirm journal todo show reflects each change.

**To-do 677, ShiftPrompt dialog for why and how, and the refusal line.** web/src/board/ShiftPrompt.vue built on kit/Dialog.vue: dropping into Held asks Why is it held (required), dropping into Done asks How did it land (optional, empty sends closed on the board from the server default), dropping a Done card into To do asks Why reopen it (required). Enter sends, Escape cancels and the card stays. A refused shift (the API error text from transport) shows in the board bar for five seconds, the way resource/ResourceActions.vue keeps its error. Check: each prompt in the browser, cancel leaves the row untouched, a refusal from a stale board (drag a card whose row just started) shows the server words.

**To-do 678, Card menu with Move to, Assign to and Open.** web/src/board/CardMenu.vue opened from a small button on Card.vue, closing on outside click with composables/outside.js. Move to lists only the lanes in card.targets and runs the same shift flow as a drop, so keyboard and touch users can move cards. Assign to lists the agents in store.board.agents and calls api.act(todo, n, assign, {to: name}), Unassign calls it with off true. Open peeks the to-do. Check: every menu entry in the browser, and assign shows on journal todo show.

## Phase: Agents, filters and live updates
Complete when: Agents and filters narrow the board, and CLI changes show within a second (checkpoint)

AgentStrip, the agent and worker chips and the needs-you badge, the lens filters remembered across reloads, the poll and the event-driven refresh. The user looks at the whole board before it ships

**To-do 679, AgentStrip and the agent chips on cards.** web/src/board/AgentStrip.vue above the lanes: one chip per agent in store.board.agents with its name, a status dot (kit/Dot.vue) for idle, busy or compacting, a subagent mark when it has a parent, and the to-do it works. Clicking a chip sets store.board.lens.agent to it (again clears it), a small open button peeks the agent row. Card.vue shows the assigned agent and the working agent as chips (one chip when they are the same), a Reported chip when card.reported, a parked chip when the work is parked, and an exclamation badge when card.question is set, which peeks the question. Check: with a subagent assigned a row and working it, the strip and chips show it, and clicking the chip narrows the board.

**To-do 680, Plan, agent, text and done filters on the board.** In BoardPage.vue bar: a plan picker listing open plans (rows(plan)), which sets store.board.lens.plan and makes the server return only its rows with phase chips, a text field filtering cards client side by title or number, and a Show done switch (kit/Switch.vue) that hides the Done lane when off. The agent filter is the strip from the previous row. The lens is remembered across reloads through the store (journal.board.lens). Changing the plan or agent re-asks the board at once. Check: each filter in the browser, and a reload keeps the lens.

**To-do 681, Live refresh of the board on record events.** In BoardPage.vue: besides the 5 second poll, watch the id of the newest event in store.events and re-ask the board at once (the refresh function usePoll returns) when a new event has type todo, work, question, plan or agent. Also re-ask after each shift or assign reply. Check: with the board open, journal todo block, journal todo start by an agent, and journal question ask --set about=todo:n from the CLI each move the card within a second, and the network tab shows no burst of requests when many events land together.

## Phase: Ship the board
Complete when: Version, changelog, skill and web build are in, and every check passes

Changelog, version bump, the journal-kanban skill regenerated from details.py, the web build, journal check sweep and the full pytest run

**To-do 682, Ship the Kanban board.** Add a CHANGELOG.md entry describing the board, bump VERSION as the project does for a feature, regenerate the journal-kanban skill from features/kanban/details.py the way the other journal-<feature> skills are generated (skills.py), run npm run build in web, run journal check sweep and the full pytest run, and look at the board once more in the browser. Check: every check passes and the skill lists journal todo board and journal todo shift.

## Questions it asked
**What should dropping a card on Doing do?** Plan 10 refuses it: only an agent starts a to-do. In the Symfony flow of doc 30, a human moving a card to in progress is the approval to start.

**Should plans get a board of their own?** Doc 30 dragged plans as cards. Plan 10 makes to-dos the cards, with plans as chips and a filter. A plans board could drag to Active to activate and from Waiting to continue.

**Should reordering cards in a lane set their priority?** Plan 10 orders each lane by priority then number, and a drag inside a lane does nothing. A drop between two cards could set a priority between theirs.

**Where should the board live in the viewer?** Plan 10 adds Board as its own sidebar page under Home. Home already shows a to-do rail (RailTodos) grouped in almost the same states, computed in the browser from the first 25 rows.
