os-backlog: card-move with CLI-enforced column ownership (ADR-029)

Adds card-move over planka-api Cards#move; ownership rules enforced in
lib (never Next, never hitl, never out of Done, Done only for
afk-ready). afk-ready cards now skip Review per user decision; semi
still stops at Review. Skills updated to "working a card means moving
it". Suite 68 runs/153 assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBAYNm4AmczQaEHfXwai5a
This commit is contained in:
jared 2026-07-12 19:11:49 -04:00
parent d6f316008d
commit 7a264f1175
9 changed files with 248 additions and 11 deletions

View File

@ -0,0 +1,27 @@
---
id: "0029"
date: 2026-07-12
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-backlog/lib/backlog/cards.rb, plugins/os-backlog/bin/os-backlog, plugins/os-backlog/skills/capture/SKILL.md, plugins/os-backlog/skills/list/SKILL.md]
affected-components: [os-backlog]
---
# 0029 — os-backlog card-move with CLI-enforced column ownership; afk-ready cards skip Review
## Context
The os-backlog process policy always intended the AI to move the cards it works (Doing -> Review at most), but the shipped slices scoped column moves out of the CLI entirely, so worked cards piled up in their original columns with status comments instead of moving — blocked cards sat in Backlog and shipped work waited on manual mouse-driven moves. Separately, the Review -> Done human sign-off gate meant every completed autonomous card demanded per-card sifting by the user, a growing annoyance the user explicitly rejected (2026-07-12).
## Decision
Add `os-backlog card-move --board NAME --card ID --to COLUMN` backed by planka-api 0.2.0 Cards#move, with the ownership rules enforced deterministically in lib/backlog/cards.rb rather than skill prose: (1) no move into or out of Next (human-curated both directions); (2) hitl-labeled cards are never moved by the AI; (3) no move out of Done; (4) move to Done allowed only for afk-ready-labeled cards; (5) all other moves among Backlog/Doing/Waiting/Review allowed. Autonomy semantics amended: afk-ready cards skip Review — shipped + verified goes straight to Done; semi cards still stop at Review for human sign-off. Working a card now means moving it: Doing when started, Waiting + blocker comment when blocked, Review (semi) or Done (afk-ready) when shipped.
## Consequences
Easier: board state reflects reality without human mousing; blocked work is visible in Waiting; no Review pile of autonomous work to sift. Harder: mistakes in afk-ready work surface later since no human skims it before Done (accepted trade-off — 'we will deal with it then'); the guardrail table lives in code and must be changed via a superseding ADR, not a skill edit.
## Alternatives rejected
1) Keep Review -> Done as a human gate but batch it (user says 'approve review', AI moves them) — rejected by the user: still a recurring sifting chore, deferred as a possible future correction if unreviewed-Done becomes a pain point. 2) Unrestricted card-move with rules only in SKILL.md prose — rejected: prose rules are exactly what drifted out of sync with the CLI in the first place; deterministic enforcement cannot be forgotten by a future skill edit. 3) Status quo (comments instead of moves) — rejected: blocked cards lingering in Backlog created confusion.

View File

@ -35,4 +35,5 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0026 | [Unified project setup/update command: `/os-status:fix` remediates what the os-status checks flag](0026-unified-project-setup-update-command-os-status-fix-remediates-what-the-os-status-checks-flag.md) | Accepted | 2026-07-12 |
| 0027 | [All cc-os per-project state lives under `.cc-os/<plugin>/`](0027-all-cc-os-per-project-state-lives-under-cc-os-plugin.md) | Accepted | 2026-07-12 |
| 0028 | [os-shortcuts plugin for user-invoked QOL commands, bounded against hook-injection plugins](0028-os-shortcuts-plugin-for-user-invoked-qol-commands-bounded-against-hook-injection-plugins.md) | Accepted | 2026-07-12 |
| 0029 | [os-backlog card-move with CLI-enforced column ownership; afk-ready cards skip Review](0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md) | Accepted | 2026-07-12 |
<!-- adr-index:end -->

View File

@ -229,6 +229,16 @@ Registered in the local-plugins marketplace, enabled in settings.json, installed
`claude plugin install os-shortcuts@local-plugins`. Sonnet-delegated build. `/readonly`
remains a separate open card (decide vs plan mode first).
**os-backlog card-move + afk-ready-skips-Review (2026-07-12, ADR-029):** the process policy
always intended the AI to move worked cards, but the CLI had no move command, so worked/blocked
cards accumulated in place with comments. Added `card-move --board --card --to` over
planka-api `Cards#move`, with column-ownership rules enforced in `lib/backlog/cards.rb`
(never into/out of Next; hitl cards never moved; never out of Done; →Done only for
afk-ready cards). Autonomy semantics amended per user decision: afk-ready ships straight to
Done (no Review sift); semi still stops at Review. capture/list SKILL.md updated to the
"working a card means moving it" contract. Suite 68 runs/153 assertions (was 58/130).
Sonnet-delegated build.
**Remaining optional items:** additional project onboarding (one at a time, per ADR-013); bulk
vault migration. All required build steps complete as of 2026-06-15.
@ -607,7 +617,8 @@ symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10
`use`/`activate`/`stop-and-discuss`; no network), `cards.rb` (add-at-Backlog, snapshot,
attach_label, comment).
- CLI: `bin/os-backlog``board-ensure`, `activate`, `archive`, `resolve`, `card-add`,
`cards`, `snapshot`, `card-label`, `card-comment`; every path fails soft (one-line error,
`cards`, `snapshot`, `card-label`, `card-comment`, `card-move` (2026-07-12, ADR-029 —
ownership rules enforced in lib); every path fails soft (one-line error,
exit 1) when the gem/Planka is unavailable.
- Skills: `/os-backlog:capture`, `/os-backlog:list`, `/os-backlog:route` (2026-07-12, issue
#14 — tracker onboarding conversation over new `inspect`/`config-write` subcommands and

View File

@ -11,6 +11,7 @@
# os-backlog resolve < input.json
# input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]}
# os-backlog card-add --board NAME --title "..." [--description "..."]
# os-backlog card-move --board NAME --card ID --to COLUMN
# os-backlog cards --board NAME (human-readable per-list card listing)
# os-backlog snapshot --board NAME (full board state as JSON; read-only)
# os-backlog inspect (detect existing tracking for cwd repo, JSON)
@ -158,6 +159,13 @@ when "card-add"
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 "card-move"
board = require_flag(rest, "--board", "card-move")
card_id = require_flag(rest, "--card", "card-move")
to = require_flag(rest, "--to", "card-move")
client = build_client
card = Backlog::Cards.new(client: client).move(board_name: board, card_id: card_id, to: to)
puts "moved card ##{card.id} to #{to} on #{board}"
when "cards"
board = require_flag(rest, "--board", "cards")
client = build_client
@ -229,6 +237,7 @@ when nil, "-h", "--help"
archive NAME --yes
resolve (reads {"repo_path","config","boards"} JSON from stdin)
card-add --board NAME --title "..." [--description "..."]
card-move --board NAME --card ID --to COLUMN
cards --board NAME
snapshot --board NAME
card-label --board NAME --card ID --label NAME

View File

@ -2,11 +2,14 @@ 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.
# 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 moves cards between columns and never deletes anything.
# Never deletes anything.
class Cards
# @param client [Planka::Client] an already-authenticated client
def initialize(client:)
@ -71,8 +74,63 @@ module Backlog
@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

View File

@ -7,9 +7,10 @@ Capture a piece of work as a card on the right Planka board, cheaply, without in
## 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.
- **Working a card means moving it**, via `card-move` (see below): Doing when work starts, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified.
- **Next stays human-curated in both directions** — never move a card into or out of Next.
- **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI.
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here — a violating move fails with a one-line reason naming the rule.
- Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later.
## Procedure
@ -39,6 +40,14 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
```
5. Confirm to the user in one line: board, title. Then return to the interrupted task.
## Moving a card
When work on a card starts, blocks, or ships, move it with:
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-move --board <board> --card <ID> --to Backlog|Doing|Waiting|Review|Done
```
The CLI refuses (one-line reason, exit 1) any move into/out of Next, any move of a `hitl` card, any move out of Done, or a move to Done without the `afk-ready` label.
## 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

@ -10,9 +10,11 @@ This skill is strictly pull-based. **Never volunteer board state**: not at sessi
## 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.
Listing is read-only — this skill never moves cards itself. While discussing the listed cards:
- Working a card means moving it (via `card-move`, see `/os-backlog:capture`): Doing when started, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified.
- Next stays human-curated in both directions — never move a card into or out of Next.
- If the user asks you to start work from the list, cards labeled `hitl` are off-limits — never moved by the AI, flag them back to the human instead.
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here.
## Procedure

View File

@ -109,4 +109,115 @@ class CardsTest < Minitest::Test
assert_equal "audit: in Doing 9 days", comment.text
assert_equal 1, @client.comments.store.size
end
# -- move -----------------------------------------------------------
def list_named(name)
@client.lists_store.find { |l| l.name == name }
end
def move_card_to(list_name, card = nil)
card ||= @cards.add(board_name: "llf-schema", title: "moving card")
@client.cards.move(card.id, list_id: list_named(list_name).id, position: 65_536) unless list_name == "Backlog"
card
end
def label!(card, name)
label = @client.labels_store.find { |l| l.name == name }
@client.attach_label(card.id, label.id)
end
def test_move_backlog_to_doing
card = @cards.add(board_name: "llf-schema", title: "task")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
assert_equal list_named("Doing").id, moved.list_id
end
def test_move_doing_to_review
card = move_card_to("Doing")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Review")
assert_equal list_named("Review").id, moved.list_id
end
def test_move_afk_ready_doing_to_done
card = move_card_to("Doing")
label!(card, "afk-ready")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
assert_equal list_named("Done").id, moved.list_id
end
def test_move_positions_card_at_end_of_destination_list
first = move_card_to("Doing")
second = @cards.add(board_name: "llf-schema", title: "second")
@cards.move(board_name: "llf-schema", card_id: first.id, to: "Waiting")
moved_second = @cards.move(board_name: "llf-schema", card_id: second.id, to: "Waiting")
waiting_first = @client.cards.all_for_board(list_named("Waiting").board_id)
.find { |c| c.id == first.id }
assert_operator moved_second.position, :>, waiting_first.position
end
def test_move_into_next_refused
card = @cards.add(board_name: "llf-schema", title: "task")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Next")
end
assert_match(/Next is human-curated/, error.message)
end
def test_move_out_of_next_refused
card = move_card_to("Next")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/Next is human-curated/, error.message)
end
def test_move_hitl_card_refused
card = @cards.add(board_name: "llf-schema", title: "task")
label!(card, "hitl")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/hitl cards are human-owned/, error.message)
end
def test_move_to_done_without_afk_ready_refused
card = move_card_to("Doing")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
end
assert_match(/afk-ready/, error.message)
end
def test_move_out_of_done_refused
card = move_card_to("Doing")
label!(card, "afk-ready")
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/cannot move a card out of Done/, error.message)
end
def test_move_unknown_column_refused
card = @cards.add(board_name: "llf-schema", title: "task")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Someplace")
end
assert_match(/unknown list/, error.message)
end
end

View File

@ -162,6 +162,15 @@ module BacklogTestHelpers
def all_for_board(board_id)
@client.cards_store.select { |c| c.board_id == board_id }
end
def move(id, list_id:, position:, board_id: nil)
card = @client.cards_store.find { |c| c.id == id }
idx = @client.cards_store.index(card)
updated = card.with(list_id: list_id, position: position)
updated = updated.with(board_id: board_id) if board_id
@client.cards_store[idx] = updated
updated
end
end
class LabelsResource