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:
parent
8d4b1c7f46
commit
3bcf3a1b48
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue