os-backlog: capture + list skills, card CLI (refs #13)

/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XknQRvihHDpYE47RTmUR4N
This commit is contained in:
jared 2026-07-10 13:41:44 -04:00
parent 8d4b1c7f46
commit 3bcf3a1b48
7 changed files with 328 additions and 6 deletions

View File

@ -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} <value>")
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 <command> [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

View File

@ -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"

View File

@ -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

View File

@ -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": "<repo root>", "config": <config-or-null>, "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 <board>` → proceed to step 3.
- `activate <archived board>` → run `os-backlog activate <name>` (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 <board> --project <Dev|Clients>
```
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 <board> --title "<short imperative title>" [--description "<context: repo, file paths, why>"]
```
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.

View File

@ -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 <board>
```
For a structured view (filtering, counting), use JSON instead:
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <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.

View File

@ -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

View File

@ -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