From 7a264f1175980feb293c9ef5015fabf50ce27920 Mon Sep 17 00:00:00 2001 From: jared Date: Sun, 12 Jul 2026 19:11:49 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01QBAYNm4AmczQaEHfXwai5a --- ...n-ownership-afk-ready-cards-skip-review.md | 27 +++++ docs/adr/README.md | 1 + docs/implementation-status.md | 13 +- plugins/os-backlog/bin/os-backlog | 9 ++ plugins/os-backlog/lib/backlog/cards.rb | 66 ++++++++++- plugins/os-backlog/skills/capture/SKILL.md | 15 ++- plugins/os-backlog/skills/list/SKILL.md | 8 +- plugins/os-backlog/tests/cards_test.rb | 111 ++++++++++++++++++ plugins/os-backlog/tests/test_helper.rb | 9 ++ 9 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md diff --git a/docs/adr/0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md b/docs/adr/0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md new file mode 100644 index 0000000..1f57271 --- /dev/null +++ b/docs/adr/0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index ce5ed02..65f89bc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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//`](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 | diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 93c51d8..b161836 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -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 diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 67a6795..4c7214a 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -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 diff --git a/plugins/os-backlog/lib/backlog/cards.rb b/plugins/os-backlog/lib/backlog/cards.rb index a80e4c4..cec5b87 100644 --- a/plugins/os-backlog/lib/backlog/cards.rb +++ b/plugins/os-backlog/lib/backlog/cards.rb @@ -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 diff --git a/plugins/os-backlog/skills/capture/SKILL.md b/plugins/os-backlog/skills/capture/SKILL.md index 10fcd3b..d1b2b96 100644 --- a/plugins/os-backlog/skills/capture/SKILL.md +++ b/plugins/os-backlog/skills/capture/SKILL.md @@ -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 --card --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. diff --git a/plugins/os-backlog/skills/list/SKILL.md b/plugins/os-backlog/skills/list/SKILL.md index 207ef7a..fdd9408 100644 --- a/plugins/os-backlog/skills/list/SKILL.md +++ b/plugins/os-backlog/skills/list/SKILL.md @@ -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 diff --git a/plugins/os-backlog/tests/cards_test.rb b/plugins/os-backlog/tests/cards_test.rb index 8655f1f..8a8617b 100644 --- a/plugins/os-backlog/tests/cards_test.rb +++ b/plugins/os-backlog/tests/cards_test.rb @@ -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 diff --git a/plugins/os-backlog/tests/test_helper.rb b/plugins/os-backlog/tests/test_helper.rb index a147a33..7db7a9b 100644 --- a/plugins/os-backlog/tests/test_helper.rb +++ b/plugins/os-backlog/tests/test_helper.rb @@ -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