From 3bcf3a1b4891a8f17ef48f813d4c1db598cf136d Mon Sep 17 00:00:00 2001 From: jared Date: Fri, 10 Jul 2026 13:41:44 -0400 Subject: [PATCH] os-backlog: capture + list skills, card CLI (refs #13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /os-backlog:capture resolves repo -> board via the pure resolver, runs board-ensure transparently when the board is missing, and creates the card at Backlog only. /os-backlog:list is strictly pull-based — board state is never injected unasked. Both skills state the column-ownership rules (create at Backlog only, move Doing -> Review at most, never pull Next, never move Review -> Done, never self-assign hitl cards) and fail soft when the gem/Planka is unavailable. New Backlog::Cards lib class + card-add / cards / snapshot subcommands; fake-client unit tests, no live API. Manual live verification is still pending: the planka-api gem is not installed in this environment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XknQRvihHDpYE47RTmUR4N --- plugins/os-backlog/bin/os-backlog | 49 +++++++++++++- plugins/os-backlog/lib/backlog.rb | 1 + plugins/os-backlog/lib/backlog/cards.rb | 79 ++++++++++++++++++++++ plugins/os-backlog/skills/capture/SKILL.md | 44 ++++++++++++ plugins/os-backlog/skills/list/SKILL.md | 32 +++++++++ plugins/os-backlog/tests/cards_test.rb | 66 ++++++++++++++++++ plugins/os-backlog/tests/test_helper.rb | 63 +++++++++++++++-- 7 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 plugins/os-backlog/lib/backlog/cards.rb create mode 100644 plugins/os-backlog/skills/capture/SKILL.md create mode 100644 plugins/os-backlog/skills/list/SKILL.md create mode 100644 plugins/os-backlog/tests/cards_test.rb diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 8196c55..20230c8 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -1,7 +1,8 @@ #!/usr/bin/env ruby # frozen_string_literal: true # -# os-backlog CLI: board-ensure, activate, archive, resolve. +# os-backlog CLI: board-ensure, activate, archive, resolve, card-add, +# cards, snapshot. # # Usage: # os-backlog board-ensure NAME --project Dev|Clients @@ -9,6 +10,9 @@ # os-backlog archive NAME --yes # os-backlog resolve < input.json # input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]} +# os-backlog card-add --board NAME --title "..." [--description "..."] +# os-backlog cards --board NAME (human-readable per-list card listing) +# os-backlog snapshot --board NAME (full board state as JSON; read-only) # # board-ensure/activate/archive talk to Planka over the network (the # installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/ @@ -45,8 +49,21 @@ def parse_project_flag(args) project || fail_soft("--project requires a value (Dev or Clients)") end +def parse_flag(args, flag) + idx = args.index(flag) + return nil unless idx + + args.delete_at(idx) + args.delete_at(idx) +end + +def require_flag(args, flag, command) + parse_flag(args, flag) || fail_soft("#{command} requires #{flag} ") +end + command, *rest = ARGV +begin case command when "board-ensure" name = rest.shift @@ -79,6 +96,28 @@ when "resolve" boards: input.fetch("boards", []) ) puts resolver.resolve_string +when "card-add" + board = require_flag(rest, "--board", "card-add") + title = require_flag(rest, "--title", "card-add") + description = parse_flag(rest, "--description") + client = build_client + card = Backlog::Cards.new(client: client).add(board_name: board, title: title, description: description) + puts "created card ##{card['id']} at Backlog on #{board}: #{card['name']}" +when "cards" + board = require_flag(rest, "--board", "cards") + client = build_client + snapshot = Backlog::Cards.new(client: client).snapshot(board_name: board) + snapshot["lists"].each do |list| + puts "#{list['name']} (#{list['cards'].size})" + list["cards"].each do |card| + labels = card["labels"].empty? ? "" : " [#{card['labels'].join(', ')}]" + puts " - #{card['name']}#{labels}" + end + end +when "snapshot" + board = require_flag(rest, "--board", "snapshot") + client = build_client + puts JSON.pretty_generate(Backlog::Cards.new(client: client).snapshot(board_name: board)) when nil, "-h", "--help" puts <<~USAGE usage: os-backlog [options] @@ -88,8 +127,16 @@ when nil, "-h", "--help" activate NAME archive NAME --yes resolve (reads {"repo_path","config","boards"} JSON from stdin) + card-add --board NAME --title "..." [--description "..."] + cards --board NAME + snapshot --board NAME USAGE exit(command.nil? ? 1 : 0) else fail_soft("unknown command #{command.inspect}") end +rescue SystemExit + raise +rescue StandardError => e + fail_soft(e.message) +end diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb index 87db9db..ce6038b 100644 --- a/plugins/os-backlog/lib/backlog.rb +++ b/plugins/os-backlog/lib/backlog.rb @@ -1,4 +1,5 @@ require_relative "backlog/board_spec" require_relative "backlog/board_ensurer" +require_relative "backlog/cards" require_relative "backlog/config" require_relative "backlog/resolver" diff --git a/plugins/os-backlog/lib/backlog/cards.rb b/plugins/os-backlog/lib/backlog/cards.rb new file mode 100644 index 0000000..551a56d --- /dev/null +++ b/plugins/os-backlog/lib/backlog/cards.rb @@ -0,0 +1,79 @@ +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), and build + # a read-only board snapshot (lists with their cards, labels, card-label + # assignments) for the list skill and the triage/audit agents. + # + # Never moves cards between columns and 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 [Hash] 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" => [{card fields + "labels"}] }] } + def snapshot(board_name:) + board = find_board!(board_name) + included = @client.boards.get(board["id"])["included"] + labels_by_id = (included["labels"] || []).to_h { |l| [l["id"], l["name"]] } + card_labels = (included["cardLabels"] || []).group_by { |cl| cl["cardId"] } + cards_by_list = (included["cards"] || []).group_by { |c| c["listId"] } + + lists = (included["lists"] || []) + .select { |l| l["name"] } # skip built-in archive/trash lists (name: null) + .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["labelId"]] } + card.merge("labels" => label_names) + end + { "name" => list["name"], "cards" => cards } + end + + { "board" => board["name"], "labels" => labels_by_id.values, "lists" => lists } + end + + private + + 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) + included = @client.boards.get(board["id"])["included"] + positions = (included["cards"] || []) + .select { |c| c["listId"] == list["id"] } + .filter_map { |c| c["position"] } + (positions.max || 0) + 65_536 + end + end +end diff --git a/plugins/os-backlog/skills/capture/SKILL.md b/plugins/os-backlog/skills/capture/SKILL.md new file mode 100644 index 0000000..10fcd3b --- /dev/null +++ b/plugins/os-backlog/skills/capture/SKILL.md @@ -0,0 +1,44 @@ +--- +description: Capture work discovered mid-session as a Backlog card on the current repo's Planka board — resolves the board deterministically from the repo path, creates a missing board transparently via board-ensure, and always lands the card at Backlog. Use unprompted WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will not be done in this session. Invoked by `/os-backlog:capture`. +--- + +Capture a piece of work as a card on the right Planka board, cheaply, without interrupting the current task. + +## Column-ownership rules (non-negotiable) + +- **The AI creates cards at Backlog only.** Never create a card in any other list. +- **Never pull cards into or out of Next** — Next is human-curated. +- **The AI moves cards Doing → Review at most.** Never move a card past Review; never move Review → Done. Done is a human sign-off. +- **Never self-assign or start work on a card labeled `hitl`** — those require the human in the loop. +- Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later. + +## Procedure + +All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`. + +1. **Resolve the board** for the current repo. Gather the inputs and run the pure resolver (no network): + ```bash + # config: contents of .cc-os/config if present, else null + # boards: names from Planka — every project's boards, including archived--* ones + echo '{"repo_path": "", "config": , "boards": [...]}' \ + | ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog resolve + ``` + To build the `boards` inventory, list boards via `snapshot`/the Planka client; if Planka is unreachable, stop here and report the error — do not guess. +2. **Act on the decision:** + - `use ` → proceed to step 3. + - `activate ` → run `os-backlog activate ` (activation is automatic — an archived board matching this repo is simply renamed back), then proceed. + - `stop-and-discuss` → do NOT create anything. Tell the user the repo doesn't map to a board and ask where the card should go. +3. **Ensure the board exists** (idempotent — safe to run every time): + ```bash + ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure --project + ``` + Project is `Dev` for repos under `~/dev/`, `Clients` for repos under `~/clients/`. +4. **Create the card at Backlog:** + ```bash + ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-add --board --title "" [--description ""] + ``` +5. Confirm to the user in one line: board, title. Then return to the interrupted task. + +## Failure behavior + +Fail soft. If the planka-api gem or the Planka instance is unavailable, the CLI prints one clear error and exits nonzero — report that error to the user verbatim and offer to note the task elsewhere. Never retry destructively, never leave partial state unmentioned. diff --git a/plugins/os-backlog/skills/list/SKILL.md b/plugins/os-backlog/skills/list/SKILL.md new file mode 100644 index 0000000..207ef7a --- /dev/null +++ b/plugins/os-backlog/skills/list/SKILL.md @@ -0,0 +1,32 @@ +--- +description: Show the Planka backlog cards relevant to the current repo, on demand. Pull-only — ONLY use when the user explicitly asks what's on the board / backlog / what's next; never inject board state into a session unasked. Invoked by `/os-backlog:list`. +--- + +Show the current repo's board state when — and only when — the user asks for it. + +## Pull-only rule (notification policy v2) + +This skill is strictly pull-based. **Never volunteer board state**: not at session start, not after a capture, not "while you're here". The only trigger is an explicit user request ("what's on the backlog?", "/os-backlog:list", "show the board"). + +## Column-ownership rules (also apply here) + +Listing is read-only. While discussing the listed cards: +- Never pull cards into or out of Next; never move anything past Review; never move Review → Done. +- If the user asks you to start work from the list, cards labeled `hitl` are off-limits for self-assignment — flag them back to the human instead. + +## Procedure + +1. Resolve the board for the current repo the same way capture does (`resolve` — see `/os-backlog:capture`). On `stop-and-discuss`, ask the user which board they mean instead of guessing. +2. Show the cards: + ```bash + ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog cards --board + ``` + For a structured view (filtering, counting), use JSON instead: + ```bash + ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board + ``` +3. Relay the listing compactly, in board-column order (Backlog, Next, Doing, Waiting, Review, Done). If the user asked a narrower question ("what's in review?"), answer only that. + +## Failure behavior + +Fail soft: if the planka-api gem or Planka is unreachable, the CLI prints one clear error and exits nonzero — relay it and stop. Never fabricate board state. diff --git a/plugins/os-backlog/tests/cards_test.rb b/plugins/os-backlog/tests/cards_test.rb new file mode 100644 index 0000000..ca4615c --- /dev/null +++ b/plugins/os-backlog/tests/cards_test.rb @@ -0,0 +1,66 @@ +require_relative "test_helper" + +class CardsTest < Minitest::Test + include BacklogTestHelpers + + def setup + @client = FakePlankaClient.new + @ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1") + @ensurer.ensure("llf-schema", project_name: "Dev") + @cards = Backlog::Cards.new(client: @client) + end + + def test_add_creates_card_at_backlog_list + card = @cards.add(board_name: "llf-schema", title: "Fix the flaky import") + + backlog = @client.lists_store.find { |l| l.name == "Backlog" } + assert_equal backlog.id, card["listId"] + assert_equal "Fix the flaky import", card["name"] + assert_equal "project", card["type"] + end + + def test_add_positions_new_card_after_existing_backlog_cards + first = @cards.add(board_name: "llf-schema", title: "one") + second = @cards.add(board_name: "llf-schema", title: "two") + + assert_operator second["position"], :>, first["position"] + end + + def test_add_with_description + card = @cards.add(board_name: "llf-schema", title: "t", description: "details here") + + assert_equal "details here", card["description"] + end + + def test_add_raises_for_missing_board + error = assert_raises(RuntimeError) { @cards.add(board_name: "nope", title: "t") } + assert_match(/no board named/, error.message) + end + + def test_snapshot_shape_and_list_order + @cards.add(board_name: "llf-schema", title: "captured task") + + snapshot = @cards.snapshot(board_name: "llf-schema") + + assert_equal "llf-schema", snapshot["board"] + assert_equal Backlog::BoardSpec::LISTS, snapshot["lists"].map { |l| l["name"] } + backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" } + assert_equal ["captured task"], backlog["cards"].map { |c| c["name"] } + assert_equal Backlog::BoardSpec::LABEL_NAMES.sort, snapshot["labels"].sort + end + + def test_snapshot_includes_card_label_names + card = @cards.add(board_name: "llf-schema", title: "labeled task") + p1 = @client.labels_store.find { |l| l.name == "P1" } + @client.attach_label(card["id"], p1.id) + + snapshot = @cards.snapshot(board_name: "llf-schema") + + backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" } + assert_equal ["P1"], backlog["cards"].first["labels"] + end + + def test_snapshot_raises_for_missing_board + assert_raises(RuntimeError) { @cards.snapshot(board_name: "nope") } + end +end diff --git a/plugins/os-backlog/tests/test_helper.rb b/plugins/os-backlog/tests/test_helper.rb index b4ae1bc..65d3b80 100644 --- a/plugins/os-backlog/tests/test_helper.rb +++ b/plugins/os-backlog/tests/test_helper.rb @@ -16,10 +16,11 @@ module BacklogTestHelpers class FakePlankaClient Project = Struct.new(:id, :name, :type, :ownerProjectManagerId, :managers, keyword_init: true) Board = Struct.new(:id, :project_id, :name, keyword_init: true) - List = Struct.new(:id, :board_id, :name, :type, keyword_init: true) + List = Struct.new(:id, :board_id, :name, :type, :position, keyword_init: true) Label = Struct.new(:id, :board_id, :name, :color, keyword_init: true) + Card = Struct.new(:id, :list_id, :board_id, :name, :type, :position, :description, keyword_init: true) - attr_reader :projects, :boards, :lists, :labels + attr_reader :projects, :boards, :lists, :labels, :cards def initialize(reject_colors: []) @next_id = 0 @@ -27,16 +28,24 @@ module BacklogTestHelpers @boards_store = [] @lists_store = [] @labels_store = [] + @cards_store = [] + @card_labels_store = [] @projects = ProjectsResource.new(self) @boards = BoardsResource.new(self) @lists = ListsResource.new(self) @labels = LabelsResource.new(self, reject_colors: reject_colors) + @cards = CardsResource.new(self) end def next_id = (@next_id += 1).to_s - attr_reader :projects_store, :boards_store, :lists_store, :labels_store + attr_reader :projects_store, :boards_store, :lists_store, :labels_store, + :cards_store, :card_labels_store + + def attach_label(card_id, label_id) + @card_labels_store << { "id" => next_id, "cardId" => card_id, "labelId" => label_id } + end class ProjectsResource def initialize(client) = @client = client @@ -88,6 +97,19 @@ module BacklogTestHelpers stringify(board) end + def get(id) + board = @client.boards_store.find { |b| b.id == id } + { + "item" => stringify(board), + "included" => { + "lists" => @client.lists.list(id), + "labels" => @client.labels.list(id), + "cards" => @client.cards.all_for_board(id), + "cardLabels" => @client.card_labels_store.dup + } + } + end + private def stringify(b) = { "id" => b.id, "projectId" => b.project_id, "name" => b.name } @@ -103,14 +125,45 @@ module BacklogTestHelpers def create(board_id, attrs) raise ArgumentError, "list create requires type" unless attrs[:type] - list = FakePlankaClient::List.new(id: @client.next_id, board_id: board_id, name: attrs[:name], type: attrs[:type]) + list = FakePlankaClient::List.new(id: @client.next_id, board_id: board_id, name: attrs[:name], + type: attrs[:type], position: attrs[:position]) @client.lists_store << list stringify(list) end private - def stringify(l) = { "id" => l.id, "boardId" => l.board_id, "name" => l.name, "type" => l.type } + def stringify(l) + { "id" => l.id, "boardId" => l.board_id, "name" => l.name, "type" => l.type, "position" => l.position } + end + end + + class CardsResource + def initialize(client) = @client = client + + def create(list_id, attrs) + raise ArgumentError, "card create requires type" unless attrs[:type] + + list = @client.lists_store.find { |l| l.id == list_id } + card = FakePlankaClient::Card.new( + id: @client.next_id, list_id: list_id, board_id: list.board_id, + name: attrs[:name], type: attrs[:type], position: attrs[:position], + description: attrs[:description] + ) + @client.cards_store << card + stringify(card) + end + + def all_for_board(board_id) + @client.cards_store.select { |c| c.board_id == board_id }.map { |c| stringify(c) } + end + + private + + def stringify(c) + { "id" => c.id, "listId" => c.list_id, "boardId" => c.board_id, "name" => c.name, + "type" => c.type, "position" => c.position, "description" => c.description } + end end class LabelsResource