require_relative "board_spec" module Backlog # Card-level operations over an authenticated Planka client: create a # card at Backlog (the only column the AI ever creates into), build a # read-only board snapshot (lists with their cards, labels, card-label # assignments) for the list skill and the triage/audit agents, and move # cards between columns under the deterministic policy guardrails # enforced by #move (Next is human-curated, hitl cards are human-owned, # Done is a one-way autonomous door gated on the afk-ready label). # # Never deletes anything. class Cards # @param client [Planka::Client] an already-authenticated client def initialize(client:) @client = client end # Create a card at the board's Backlog list. Raises if the board or # its Backlog list doesn't exist (callers run board-ensure first). # # @return [Planka::Types::Card] the created card def add(board_name:, title:, description: nil) board = find_board!(board_name) backlog = find_list!(board, "Backlog") attrs = { name: title, type: "project", position: next_position(board, backlog) } attrs[:description] = description if description @client.cards.create(backlog.id, attrs) end # Read-only snapshot of a whole board: every list in BoardSpec order # (plus any extras), each with its cards and their label names. # # @return [Hash] { "board" => name, "labels" => [names], # "lists" => [{ "name", "cards" => [{"id","name","labels",...card fields}] }] } def snapshot(board_name:) board = find_board!(board_name) detail = @client.boards.get(board.id) labels_by_id = detail.labels.to_h { |l| [l.id, l.name] } card_labels = detail.card_labels.group_by(&:card_id) cards_by_list = detail.cards.group_by(&:list_id) lists = detail.lists .select(&:name) # skip built-in archive/trash lists (name: nil) .sort_by { |l| l.position || Float::INFINITY } .map do |list| cards = (cards_by_list[list.id] || []).map do |card| label_names = (card_labels[card.id] || []).filter_map { |cl| labels_by_id[cl.label_id] } card_hash(card, label_names) end { "name" => list.name, "cards" => cards } end { "board" => board.name, "labels" => labels_by_id.values, "lists" => lists } end # Attach a board label (by name) to a card. The only card mutation the # triage agent is allowed — label changes, never column moves. # # @return [Planka::Types::CardLabel] the card-label join record def attach_label(board_name:, card_id:, label:) board = find_board!(board_name) record = @client.labels.list(board.id).find { |l| l.name == label } raise "board #{board_name.inspect} has no label #{label.inspect} (run board-ensure)" unless record @client.labels.attach_to_card(card_id, record.id) end # Add a comment to a card. The only card mutation the audit agent is # allowed. # # @return [Planka::Types::Comment] the created comment def comment(card_id:, text:) @client.comments.create(card_id, text: text) end # Move a card to another list on the same board, enforcing the # deterministic column-ownership policy (this is code, not skill # prose, precisely so it can't be talked around): # # 1. Any move into or out of Next is refused — Next is human-curated. # 2. Any move of a card labeled hitl is refused — hitl cards are # human-owned. # 3. Moving a card out of Done is refused. # 4. Moving a card to Done is allowed only if it carries the # afk-ready label (afk-ready work that's shipped+verified goes # straight to Done; semi work stops at Review for human sign-off). # 5. Everything else among Backlog/Doing/Waiting/Review is allowed. # # Lands the card at the end of the destination list (max existing # position + 65536, or 65536 if the list is empty). # # @param board_name [String] # @param card_id [String, Integer] # @param to [String] destination list name (one of BoardSpec::LISTS) # @return [Planka::Types::Card] the moved card def move(board_name:, card_id:, to:) unless BoardSpec::LISTS.include?(to) raise "unknown list #{to.inspect} (expected one of #{BoardSpec::LISTS.join(', ')})" end board = find_board!(board_name) detail = @client.boards.get(board.id) card = detail.cards.find { |c| c.id == card_id } raise "no card ##{card_id} found on board #{board_name.inspect}" unless card from_name = detail.lists.find { |l| l.id == card.list_id }&.name to_list = detail.lists.find { |l| l.name == to } raise "board #{board_name.inspect} has no #{to.inspect} list (run board-ensure)" unless to_list enforce_move_policy!(from_name: from_name, to_name: to, card: card, detail: detail) position = next_position(board, to_list) @client.cards.move(card_id, list_id: to_list.id, position: position) end private def enforce_move_policy!(from_name:, to_name:, card:, detail:) raise "Next is human-curated" if from_name == "Next" || to_name == "Next" label_names = detail.card_labels .select { |cl| cl.card_id == card.id } .filter_map { |cl| detail.labels.find { |l| l.id == cl.label_id }&.name } raise "hitl cards are human-owned" if label_names.include?("hitl") raise "cannot move a card out of Done" if from_name == "Done" && to_name != "Done" return unless to_name == "Done" && !label_names.include?("afk-ready") raise "move to Done requires the afk-ready label" end # Build the plain-hash card representation the CLI/JSON output emits, # keeping the output shape byte-compatible with the 0.1.x snapshot # (string keys, "labels" merged in) even though Types::Card is an # immutable Data object we can no longer +merge+. Emits every # Types::Card field — dropping fields here silently breaks consumers # like board-audit, which reads "listChangedAt". def card_hash(card, label_names) { "id" => card.id, "createdAt" => card.created_at, "updatedAt" => card.updated_at, "type" => card.type, "position" => card.position, "name" => card.name, "description" => card.description, "dueDate" => card.due_date, "isDueCompleted" => card.is_due_completed, "stopwatch" => card.stopwatch, "commentsTotal" => card.comments_total, "isClosed" => card.is_closed, "listChangedAt" => card.list_changed_at, "boardId" => card.board_id, "listId" => card.list_id, "creatorUserId" => card.creator_user_id, "prevListId" => card.prev_list_id, "coverAttachmentId" => card.cover_attachment_id, "isSubscribed" => card.is_subscribed, "labels" => label_names } end def find_board!(name) @client.projects.list.each do |project| board = @client.boards.list(project.id).find { |b| b.name == name } return board if board end raise "no board named #{name.inspect} found (run board-ensure first)" end def find_list!(board, list_name) list = @client.lists.list(board.id).find { |l| l.name == list_name } raise "board #{board.name.inspect} has no #{list_name.inspect} list (run board-ensure)" unless list list end def next_position(board, list) detail = @client.boards.get(board.id) positions = detail.cards .select { |c| c.list_id == list.id } .filter_map(&:position) (positions.max || 0) + 65_536 end end end