feat: Add openspec-sync-specs and openspec-verify-change skills

- Introduced `openspec-sync-specs` skill to sync delta specs to main specs, allowing intelligent merging of requirements.
- Added `openspec-verify-change` skill to verify implementation against change artifacts, ensuring completeness, correctness, and coherence before archiving.

docs: Create CLAUDE.md for project guidance

- Added CLAUDE.md to provide an overview of the PCLI project, including build, test commands, architecture, and resource addition guidelines.

chore: Add new change and design documents for project filter in status command

- Created `.openspec.yaml`, `design.md`, `proposal.md`, and `tasks.md` for the `add-project-filter-to-status` change.
- Updated specs for CLI commands and status command to include project filtering functionality.

feat: Expand board included parsing in API client

- Added parsing for `labels`, `cardLabels`, and `cardMemberships` in the `GetBoard` response.
- Updated `ListCardsByBoard` to enrich card output with label names, enhancing usability in kanban sync workflows.
This commit is contained in:
Steve Cliff
2026-02-18 21:27:02 +00:00
parent 94dffdf8fc
commit 22d5848e1a
44 changed files with 494 additions and 77 deletions
@@ -1,11 +1,9 @@
---
name: "Kanban Sync"
description: Reconcile Planka board state with OpenSpec changes. Planka is a read-only dashboard - OpenSpec is the source of truth.
category: Workflow
tags: [workflow, kanban, planka, tasks, project-management, sync]
description: Reconcile Planka board state with OpenSpec changes
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion
---
# Planka OpenSpec Reconciliation Sync
# Planka <-> OpenSpec Reconciliation Sync
**OpenSpec is the source of truth for all agentic work.** Planka provides a read-only view so humans and other agents can see what's happening. This workflow reads OpenSpec state and reconciles Planka to match.
@@ -25,7 +23,7 @@ If this fails, skip the Planka sync silently. Planka sync is best-effort - never
1. **OpenSpec owns the state** - `tasks.md`, artifacts, and change status live in OpenSpec
2. **Planka is a projection** - it reflects OpenSpec state, never the other way around
3. **Sync is one-directional** - OpenSpec Planka, never Planka OpenSpec
3. **Sync is one-directional** - OpenSpec -> Planka, never Planka -> OpenSpec
4. **Sync is idempotent** - running it twice produces the same result
5. **Sync is best-effort** - if Planka is down, work continues uninterrupted
6. **Non-agentic work** is managed directly in Planka (no OpenSpec involvement)
@@ -34,7 +32,7 @@ If this fails, skip the Planka sync silently. Planka sync is best-effort - never
## When This Runs
This workflow is triggered automatically after any opsx workflow completes (via project-level instruction in `.windsurf/rules/kanban-update.md`). It can also be invoked manually via `/kanban-tasks`.
This workflow is triggered automatically after any opsx workflow completes (via project-level instruction in `CLAUDE.md`). It can also be invoked manually via `/kanban-sync`.
---
@@ -81,7 +79,7 @@ PROJECT_ID=$(pcli project create --name "$PROJECT_NAME" --type "public" | jq -r
```bash
# Get project details to find boards
BOARD_ID=$(pcli project get $PROJECT_ID | jq -r --arg name "$BOARD_NAME" '.data.included.boards[] | select(.name == $name) | .id')
BOARD_ID=$(pcli board list --project "$PROJECT_NAME" | jq -r --arg name "$BOARD_NAME" '.data[] | select(.name == $name) | .id')
```
If no board found:
@@ -93,7 +91,7 @@ BOARD_ID=$(pcli board create --project $PROJECT_ID --name "$BOARD_NAME" | jq -r
After obtaining the board, get its current lists:
```bash
EXISTING_LISTS=$(pcli board get $BOARD_ID | jq -r '.data.included.lists[]? | .name')
EXISTING_LISTS=$(pcli board get $BOARD_ID | jq -r '.data.lists[]? | .name')
```
Create any missing lists with explicit positions to maintain correct ordering:
@@ -112,7 +110,7 @@ Skip any list that already exists (match by name).
### 5. Find or create the `agent` label
```bash
LABEL_ID=$(pcli board get $BOARD_ID | jq -r '.data.included.labels[]? | select(.name == "agent") | .id')
LABEL_ID=$(pcli board get $BOARD_ID | jq -r '.data.labels[]? | select(.name == "agent") | .id')
```
If no `agent` label found:
@@ -171,7 +169,7 @@ For each active OpenSpec change that has no matching Planka card:
```bash
# Determine the correct list based on change state (see step 2)
LIST_ID=$(pcli board get $BOARD_ID | jq -r --arg list "<target-list>" '.data.included.lists[] | select(.name == $list) | .id')
LIST_ID=$(pcli board get $BOARD_ID | jq -r --arg list "<target-list>" '.data.lists[] | select(.name == $list) | .id')
CARD_ID=$(pcli card create --list $LIST_ID --name "<change-name>" --description "<schema: schema-name>" | jq -r '.data.id')
# Add agent label
@@ -183,27 +181,44 @@ pcli card add-label $CARD_ID --label $LABEL_ID
For each agent-labelled card that exists but is in the wrong list (based on current OpenSpec state):
```bash
TARGET_LIST_ID=$(pcli board get $BOARD_ID | jq -r --arg list "<target-list>" '.data.included.lists[] | select(.name == $list) | .id')
TARGET_LIST_ID=$(pcli board get $BOARD_ID | jq -r --arg list "<target-list>" '.data.lists[] | select(.name == $list) | .id')
pcli card move $CARD_ID --list $TARGET_LIST_ID
```
This ensures cards move through the board as work progresses:
- `Planning` `In Progress` when artifacts are complete and apply begins
- `In Progress` `Review` when all tasks are marked done
- `Review` `Done` when the change is archived
- `Planning` -> `In Progress` when artifacts are complete and apply begins
- `In Progress` -> `Review` when all tasks are marked done
- `Review` -> `Done` when the change is archived
### 6. Reconcile: sync task lists
For each OpenSpec change that has a `tasks.md`:
- If the Planka card has no task list → create one and add all tasks
- If the Planka card has a task list → compare task names and completion state, update as needed
First, check if the card already has task lists by using `pcli card get`:
```bash
# Create task list if missing
TL_ID=$(pcli task-list create --card $CARD_ID --name "Implementation" --show-on-front | jq -r '.data.id')
# Get card details including existing task lists and tasks
CARD_DATA=$(pcli card get $CARD_ID)
EXISTING_TL=$(echo "$CARD_DATA" | jq -r '.data.taskLists[0].id // empty')
```
- If `EXISTING_TL` is empty (no task list exists) -> create one and add all tasks
- If `EXISTING_TL` is set (task list already exists) -> compare existing tasks by name and update completion state as needed; only create tasks that don't already exist
```bash
# Create task list ONLY if none exists
if [ -z "$EXISTING_TL" ]; then
TL_ID=$(pcli task-list create --card $CARD_ID --name "Implementation" --show-on-front | jq -r '.data.id')
else
TL_ID="$EXISTING_TL"
fi
# Get existing task names to avoid duplicates
EXISTING_TASKS=$(echo "$CARD_DATA" | jq -r '.data.tasks[] | select(.taskListId == "'$TL_ID'") | .name')
# For each task in tasks.md:
# - If a task with the same name already exists, update its completion state if needed
# - If no matching task exists, create it
pcli task create --task-list $TL_ID --name "<task description>"
# For tasks already in Planka, update completion state to match tasks.md:
@@ -213,10 +228,10 @@ pcli task update <task-id> --completed # if tasks.md shows [x]
### 7. Reconcile: move completed/archived changes
For each agent-labelled Planka card that has no matching active OpenSpec change:
- The change was likely archived move the card to "Done"
- The change was likely archived -> move the card to "Done"
```bash
DONE_LIST_ID=$(pcli board get $BOARD_ID | jq -r '.data.included.lists[] | select(.name == "Done") | .id')
DONE_LIST_ID=$(pcli board get $BOARD_ID | jq -r '.data.lists[] | select(.name == "Done") | .id')
pcli card move $CARD_ID --list $DONE_LIST_ID
```
@@ -237,8 +252,8 @@ After reconciliation, briefly summarise what changed:
Cards **without** the `agent` label are human-managed and fully read-write. The kanban skill (`/kanban`) handles these directly - creating cards, moving them, adding checklists, etc.
The distinction:
- **Has `agent` label** read-only projection, managed by this sync workflow
- **No `agent` label** regular Planka card, managed directly by humans
- **Has `agent` label** -> read-only projection, managed by this sync workflow
- **No `agent` label** -> regular Planka card, managed directly by humans
---
@@ -1,10 +1,6 @@
---
name: kanban
description: Manage Planka project boards using the pcli CLI. Use when the user wants to interact with Planka boards, cards, lists, tasks, labels, or comments.
compatibility: Requires pcli binary in PATH and PLANKA_URL + PLANKA_API_KEY environment variables set
metadata:
author: steve-cliff
version: "1.0"
---
# pcli - Planka CLI
@@ -128,10 +124,10 @@ Board details include lists directly in `.data.lists[]`, not in an `included` se
pcli board get <board-id> | jq '.data.lists[] | {id, name, position}'
```
### Project Get Response
Project details include boards in `.data.included.boards[]`:
### Finding Boards in a Project
Use `board list --project` to find boards by project name:
```bash
pcli project get <project-id> | jq '.data.included.boards[] | {id, name}'
pcli board list --project "<project-name>" | jq '.data[] | {id, name}'
```
## Error Handling
@@ -182,7 +178,7 @@ if [ -z "$PROJECT_ID" ]; then
fi
# Find or create board
BOARD_ID=$(pcli project get $PROJECT_ID | jq -r --arg name "$BOARD_NAME" '.data.included.boards[] | select(.name == $name) | .id')
BOARD_ID=$(pcli board list --project "$PROJECT_NAME" | jq -r --arg name "$BOARD_NAME" '.data[] | select(.name == $name) | .id')
if [ -z "$BOARD_ID" ]; then
BOARD_ID=$(pcli board create --project $PROJECT_ID --name "$BOARD_NAME" | jq -r '.data.id')
fi
-15
View File
@@ -1,15 +0,0 @@
---
trigger: always_on
---
## Planka Sync
After completing any opsx workflow (/opsx:new, /opsx:ff, /opsx:continue, /opsx:apply, /opsx:verify, /opsx:archive, /opsx:bulk-archive), automatically reconcile Planka board state with OpenSpec by:
1. Reading the kanban-sync workflow documentation at `.windsurf/workflows/kanban-sync.md`
2. Implementing the reconciliation steps programmatically using `pcli` commands
3. Following the workflow logic to sync OpenSpec changes to Planka cards
This ensures the Planka dashboard stays current without modifying the opsx workflows themselves.
If the sync fails (pcli unavailable, network issues), log a brief warning and continue. Never block or retry - Planka sync is best-effort. Do NOT attempt to execute the markdown file as a bash script - it is documentation that must be implemented programmatically.
+58
View File
@@ -0,0 +1,58 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
PCLI is a Go CLI tool for interacting with the Planka (v2.0) project management API. It uses the Cobra framework with minimal dependencies (only Cobra + standard library).
## Build & Test Commands
```bash
go build -o pcli . # Build for development
./build.sh # Multi-platform release build (linux/darwin/windows)
./test.sh # Integration tests (requires live Planka instance)
```
Integration tests require `PLANKA_URL` and `PLANKA_API_KEY` environment variables pointing to a running Planka instance.
There is no unit test suite or linter configured — tests are shell-based integration tests in `test.sh`.
## Architecture
**Entry point**: `main.go``cmd.Execute()`
**Package layout**:
- `cmd/` — Cobra command definitions, one file per resource (project.go, board.go, card.go, etc.)
- `client/` — HTTP API client, one file per resource matching cmd/ structure
- `model/types.go` — All domain types (Project, Board, Card, List, Label, Task, Comment, etc.)
- `output/output.go` — Dual-format output: JSON envelope (`{data, error}`) or human-readable table via tabwriter
- `logging/logging.go` — slog-based structured JSON logging to stderr
**Command hierarchy**: `pcli <resource> <action>` (e.g., `pcli card create --list <id> --name "task"`)
**Key patterns**:
- Global flags (`--format`, `--url`, `--api-key`, `--log-level`) set in `cmd/root.go` PersistentPreRunE, which validates config and initializes the API client
- Client methods follow `List*/Get*/Create*/Update*/Delete*` naming
- API responses use typed structs with included relationships (e.g., board responses include lists and cards)
- `friendlyAPIError()` in `cmd/errors.go` translates HTTP status codes to contextual messages
- `DoWithFallback` in client supports trying alternate API endpoint patterns
- Output uses reflection-based table rendering per type
**Configuration**: Environment variables `PLANKA_URL` and `PLANKA_API_KEY`, overridable via CLI flags.
## Adding a New Resource
1. Define model structs in `model/types.go`
2. Add client methods in `client/<resource>.go`
3. Add command definitions in `cmd/<resource>.go` with subcommands (list, get, create, update, delete)
4. Wire the parent command into `root.go` init()
## Planka Sync
**IMPORTANT**
After completing any opsx workflow (/opsx:new, /opsx:ff, /opsx:continue,
/opsx:apply, /opsx:verify, /opsx:archive, /opsx:bulk-archive), automatically
reconcile Planka board state by running `/kanban-sync`.
If the sync fails, log a brief warning and continue. Never block or retry —
Planka sync is best-effort.
+3
View File
@@ -187,6 +187,9 @@ pcli label delete <label-id>
```bash
# Show status summary of all boards and their lists
pcli status
# Show status filtered by project name
pcli status --project "MyProject"
```
## Output Formats
+8 -2
View File
@@ -43,8 +43,11 @@ func (c *Client) GetBoard(ctx context.Context, id string) (*model.Board, error)
var response struct {
Item model.Board `json:"item"`
Included struct {
Lists []model.List `json:"lists"`
Cards []model.Card `json:"cards"`
Lists []model.List `json:"lists"`
Cards []model.Card `json:"cards"`
Labels []model.Label `json:"labels"`
CardLabels []model.CardLabel `json:"cardLabels"`
CardMemberships []model.CardMembership `json:"cardMemberships"`
} `json:"included"`
}
@@ -54,6 +57,9 @@ func (c *Client) GetBoard(ctx context.Context, id string) (*model.Board, error)
response.Item.Lists = response.Included.Lists
response.Item.Cards = response.Included.Cards
response.Item.Labels = response.Included.Labels
response.Item.CardLabels = response.Included.CardLabels
response.Item.CardMemberships = response.Included.CardMemberships
return &response.Item, nil
}
+36 -3
View File
@@ -9,21 +9,31 @@ import (
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) GetCard(ctx context.Context, id string) (*model.Card, error) {
func (c *Client) GetCard(ctx context.Context, id string) (*model.CardDetail, error) {
data, err := c.DoNoBody(ctx, "GET", fmt.Sprintf("/api/cards/%s", id))
if err != nil {
return nil, err
}
var response struct {
Item model.Card `json:"item"`
Item model.Card `json:"item"`
Included struct {
TaskLists []model.TaskList `json:"taskLists"`
Tasks []model.Task `json:"tasks"`
} `json:"included"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal card response: %w", err)
}
return &response.Item, nil
result := &model.CardDetail{
Card: response.Item,
TaskLists: response.Included.TaskLists,
Tasks: response.Included.Tasks,
}
return result, nil
}
func (c *Client) CreateCard(ctx context.Context, listId string, fields map[string]any) (*model.Card, error) {
@@ -195,11 +205,34 @@ func (c *Client) ListCardsByBoard(ctx context.Context, boardId string, limit int
listNames[list.ID] = name
}
// Build label ID -> name map
labelNames := make(map[string]string)
for _, label := range board.Labels {
name := ""
if label.Name != nil {
name = *label.Name
}
labelNames[label.ID] = name
}
// Build card ID -> label names map
cardLabelNames := make(map[string][]string)
for _, cl := range board.CardLabels {
if name, ok := labelNames[cl.LabelID]; ok {
cardLabelNames[cl.CardID] = append(cardLabelNames[cl.CardID], name)
}
}
var allCards []model.CardWithList
for _, card := range board.Cards {
labels := cardLabelNames[card.ID]
if labels == nil {
labels = []string{}
}
cardWithList := model.CardWithList{
Card: card,
ListName: listNames[card.ListID],
Labels: labels,
}
allCards = append(allCards, cardWithList)
+22 -2
View File
@@ -11,15 +11,33 @@ import (
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show status summary of all boards and their lists",
Long: "Displays a summary of all boards, their lists, and the number of cards in each list",
Short: "Show status summary of boards and their lists",
Long: "Displays a summary of boards, their lists, and the number of cards in each list.\nUse --project to filter by project name.",
RunE: func(cmd *cobra.Command, args []string) error {
projectName, _ := cmd.Flags().GetString("project")
// Get all boards
boards, err := getClient().ListBoards(getContext())
if err != nil {
return fmt.Errorf("failed to list boards: %w", err)
}
// Filter boards by project if --project flag is provided
if projectName != "" {
projectID, err := resolveProjectNameToID(projectName)
if err != nil {
return err
}
filtered := boards[:0]
for _, board := range boards {
if board.ProjectID == projectID {
filtered = append(filtered, board)
}
}
boards = filtered
}
// Build status summary with error collection
summary := model.StatusSummary{
TotalBoards: len(boards),
@@ -102,4 +120,6 @@ var statusCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(statusCmd)
statusCmd.Flags().String("project", "", "Filter status by project name")
}
+1 -1
View File
@@ -49,7 +49,7 @@ Most commands require IDs. Discover them by querying first:
pcli board list | jq '.data[] | {id, title}'
# Find lists on a board
pcli board get <board-id> | jq '.data.included.lists[] | {id, title}'
pcli board get <board-id> | jq '.data.lists[] | {id, name}'
# Find cards on a list or board
pcli card list --board <board-id> | jq '.data[] | {id, name}'
+13 -3
View File
@@ -30,8 +30,11 @@ type Board struct {
ExpandTaskListsByDefault bool `json:"expandTaskListsByDefault"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
Lists []List `json:"lists,omitempty"`
Cards []Card `json:"cards,omitempty"`
Lists []List `json:"lists,omitempty"`
Cards []Card `json:"cards,omitempty"`
Labels []Label `json:"labels,omitempty"`
CardLabels []CardLabel `json:"cardLabels,omitempty"`
CardMemberships []CardMembership `json:"cardMemberships,omitempty"`
}
type List struct {
@@ -71,9 +74,16 @@ type Card struct {
UpdatedAt *string `json:"updatedAt"`
}
type CardDetail struct {
Card
TaskLists []TaskList `json:"taskLists,omitempty"`
Tasks []Task `json:"tasks,omitempty"`
}
type CardWithList struct {
Card
ListName string `json:"listName"`
ListName string `json:"listName"`
Labels []string `json:"labels"`
}
type Comment struct {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-18
@@ -0,0 +1,38 @@
## Context
The `pcli status` command fetches all boards and aggregates card counts per list. It has no filtering capability. The `board list` command already supports `--project <name>` filtering via a `resolveProjectNameToID()` helper in `cmd/board.go` that does case-insensitive name matching against all projects. The Board model already contains a `ProjectID` field.
## Goals / Non-Goals
**Goals:**
- Add `--project` flag to `pcli status` that filters boards by project name
- Reuse the existing `resolveProjectNameToID()` pattern from `cmd/board.go`
- Update help text and README to document the new flag
**Non-Goals:**
- Filtering by project ID (only name-based, consistent with `board list`)
- Multiple project filtering (single project only)
- Caching project lookups across commands
- Changing the status output structure or adding new fields
## Decisions
### Filter boards before fetching details
**Decision**: Apply the project filter on the initial `ListBoards()` result, before calling `GetBoard()` for each board. This avoids unnecessary API calls for boards that will be filtered out.
**Alternative**: Filter after fetching all board details. Rejected because it wastes API calls and time for boards not in the target project.
### Reuse resolveProjectNameToID from board.go
**Decision**: Call the existing `resolveProjectNameToID()` function directly — it is already exported within the `cmd` package.
**Alternative**: Extract to a shared utility. Rejected as premature — two call sites within the same package doesn't warrant a new abstraction.
### totalBoards reflects filtered count
**Decision**: When `--project` is used, `totalBoards` in the output reflects only the matching boards, not all boards. This matches the semantics of "how many boards am I looking at."
**Alternative**: Show total and filtered counts separately. Rejected — adds complexity for minimal value.
## Risks / Trade-offs
- **[Extra API call]** → When `--project` is used, an additional `ListProjects()` call is made to resolve the name. This is lightweight and consistent with `board list --project`.
- **[No partial match]** → Project name must match exactly (case-insensitive). Consistent with existing `board list` behavior. Users who misspell get a clear "project not found" error.
@@ -0,0 +1,30 @@
## Why
The `pcli status` command currently shows a summary of **all** boards across all projects. In instances with many projects, this produces noisy output and makes it difficult to focus on a specific project's boards. The `board list` command already supports `--project` filtering — the status command should offer the same capability for consistency and usability.
## What Changes
- Add an optional `--project` flag to `pcli status` that filters the status summary to boards belonging to the named project
- When `--project` is provided, only boards matching that project are included; `totalBoards` reflects the filtered count
- When `--project` is omitted, behavior is unchanged (all boards shown)
- Update command help text (`Short`, `Long`) to mention the filtering capability
- Update README status section with `--project` usage example
- Reuse the existing `resolveProjectNameToID()` helper from `cmd/board.go`
## Capabilities
### New Capabilities
_None — this enhances existing capabilities._
### Modified Capabilities
- `status-command`: Add optional `--project` flag for project-scoped filtering
- `cli-commands`: Update status command documentation to include `--project` flag
## Impact
- **Code**: `cmd/status.go` (add flag, filter logic), `cmd/board.go` (no change — reuses existing helper)
- **Documentation**: `README.md` status section, command help strings
- **API**: No new API calls — uses existing `ListProjects()` for name resolution, same `ListBoards()` + `GetBoard()` flow
- **Breaking changes**: None — flag is optional, default behavior preserved
@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: Status command
The system SHALL provide a top-level `status` command registered directly on the root command (not as a subcommand of any resource group). `pcli status` SHALL take no positional arguments. The command SHALL accept an optional `--project <name>` flag (string) to filter boards by project name. The command SHALL fetch all boards via `ListBoards`, optionally filter by project, then fetch each board's details via `GetBoard` sequentially, aggregate card counts per list, and output the result via the standard `output.Print` mechanism respecting the global `--format` flag.
#### Scenario: Run status command
- **WHEN** `pcli status` is executed
- **THEN** the system SHALL output a summary of all boards with their lists and card counts
#### Scenario: Run status command with project filter
- **WHEN** `pcli status --project "MyProject"` is executed
- **THEN** the system SHALL output a summary of only boards belonging to "MyProject"
#### Scenario: Status command respects format flag
- **WHEN** `pcli status --format table` is executed
- **THEN** the output SHALL be in table format
#### Scenario: Status command default format
- **WHEN** `pcli status` is executed without a `--format` flag
- **THEN** the output SHALL be in JSON envelope format
@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: Status command project filtering
The system SHALL accept an optional `--project <name>` flag on the `pcli status` command. When `--project` is provided, the system SHALL resolve the project name to a project ID using case-insensitive exact matching against all accessible projects. The system SHALL then filter the board list to include only boards whose `projectId` matches the resolved project ID. The `totalBoards` count in the output SHALL reflect the filtered board count. When `--project` is omitted, behavior SHALL be unchanged (all boards shown).
#### Scenario: Status filtered by project name
- **WHEN** `pcli status --project "MyProject"` is executed and the project exists with 2 boards
- **THEN** the output SHALL include only the 2 boards belonging to "MyProject"
- **AND** `totalBoards` SHALL be 2
#### Scenario: Status filtered by project name case-insensitive
- **WHEN** `pcli status --project "myproject"` is executed and a project named "MyProject" exists
- **THEN** the output SHALL include boards belonging to "MyProject"
#### Scenario: Status with project filter and no matching boards
- **WHEN** `pcli status --project "EmptyProject"` is executed and the project exists but has no boards
- **THEN** the output SHALL show `totalBoards` as 0 and an empty boards array
#### Scenario: Status with project not found
- **WHEN** `pcli status --project "NonExistent"` is executed and no project with that name exists
- **THEN** the system SHALL output an error "project not found: NonExistent"
- **AND** the system SHALL exit with code 1
#### Scenario: Status without project flag
- **WHEN** `pcli status` is executed without `--project`
- **THEN** the output SHALL include all boards across all projects (unchanged behavior)
## MODIFIED Requirements
### Requirement: Status command summary output
The system SHALL provide a top-level `pcli status` command that outputs a summary of all boards (or boards filtered by `--project`), their lists, and card counts. The summary SHALL include the total number of boards. For each board, the summary SHALL include the board name and a breakdown of each list within that board showing the list name, the number of open cards (where `isClosed` is false), and the number of closed cards (where `isClosed` is true). Empty lists SHALL be included in the output with 0 open and 0 closed cards.
#### Scenario: Status with multiple boards and lists
- **WHEN** `pcli status` is executed and there are boards with lists containing cards
- **THEN** the output SHALL include the total board count
- **AND** each board SHALL list all its lists with open and closed card counts
#### Scenario: Status with empty lists
- **WHEN** a board contains a list with no cards
- **THEN** that list SHALL appear in the output with 0 open cards and 0 closed cards
#### Scenario: Status with no boards
- **WHEN** `pcli status` is executed and there are no boards
- **THEN** the output SHALL indicate 0 boards
#### Scenario: Status with closed cards
- **WHEN** a list contains both open and closed cards
- **THEN** the open card count SHALL exclude closed cards
- **AND** the closed card count SHALL be shown separately
@@ -0,0 +1,19 @@
## 1. Core Implementation
- [x] 1.1 Add `--project` flag to `statusCmd` in `cmd/status.go`
- [x] 1.2 Add project filtering logic: resolve name via `resolveProjectNameToID()`, filter boards by `ProjectID` before fetching details
- [x] 1.3 Update `totalBoards` count to reflect filtered results
## 2. Command Help Text
- [x] 2.1 Update `Short` and `Long` descriptions on `statusCmd` to mention `--project` filtering
## 3. Documentation
- [x] 3.1 Update README.md status section with `--project` usage example
## 4. Verification
- [x] 4.1 Build and manually test `pcli status --project <name>` with an existing project
- [x] 4.2 Test error case: `pcli status --project "nonexistent"` returns "project not found" error
- [x] 4.3 Test default behavior: `pcli status` without `--project` still shows all boards
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-18
@@ -0,0 +1,37 @@
## Context
The Planka v2 API `GET /api/boards/:id` response includes an `included` object with: `users`, `boardMemberships`, `labels`, `lists`, `cards`, `cardMemberships`, `cardLabels`, `taskLists`, `tasks`, `attachments`, `customFieldGroups`, `customFields`, `customFieldValues`. Currently `GetBoard` only parses `lists` and `cards`, discarding everything else.
The `CardLabel` and `Label` types already exist in `model/types.go`. The `Board` struct has `Lists` and `Cards` fields but not `Labels`, `CardLabels`, or `CardMemberships`.
## Goals / Non-Goals
**Goals:**
- Parse `labels`, `cardLabels`, and `cardMemberships` from the `GetBoard` response
- Make label data available on the `Board` struct for downstream consumers
- Enrich `ListCardsByBoard` output so `card list --board` includes label names per card
**Non-Goals:**
- Parsing all included fields (users, taskLists, attachments, customFields, etc.) — only what's needed now
- Adding label data to `card list --list` (uses a different API endpoint that doesn't include labels)
- Changing the `card get` response (already returns card-level data via a different endpoint)
## Decisions
### Add fields to Board struct rather than creating a separate BoardDetail type
**Decision**: Add `Labels`, `CardLabels`, and `CardMemberships` as optional fields on the existing `Board` struct with `omitempty`.
**Alternative**: Create a `BoardDetail` struct for the enriched response. Rejected — the fields are simply absent when not fetched (e.g., `ListBoards`), and `omitempty` handles this cleanly without a type split.
### Add Labels field to CardWithList for card list --board output
**Decision**: Add a `Labels` field (`[]string` of label names) to the `CardWithList` struct. `ListCardsByBoard` will resolve card→label associations via the `cardLabels` join table and `labels` list from the board response.
**Alternative**: Return full `Label` objects per card. Rejected — label names are sufficient for display and filtering; full objects add noise to output.
### Use join-table approach matching the API structure
**Decision**: Keep `CardLabel` as the join entity (cardId + labelId), and resolve to label names at the point of use (in `ListCardsByBoard`). This mirrors the Planka API's relational structure.
## Risks / Trade-offs
- **[Additive JSON fields]** → `card list --board` output will include a new `labels` array per card. Existing consumers that don't use this field are unaffected. Scripts using strict JSON parsing may need updating.
- **[Partial included parsing]** → We still skip users, taskLists, attachments, etc. This is intentional — parse only what's needed. Future changes can add more fields incrementally.
@@ -0,0 +1,26 @@
## Why
The `GetBoard` API response includes `cardLabels`, `labels`, and `cardMemberships` in its `included` data, but `pcli` only parses `lists` and `cards` — silently discarding the rest. This means there is no way to determine which labels are attached to which cards without making per-card API calls. The kanban sync workflow needs to identify agent-labelled cards from a board listing, and currently must fall back to name-matching because label data is unavailable.
## What Changes
- Expand the `Board` struct in `model/types.go` to include `Labels`, `CardLabels`, and `CardMemberships` fields
- Update `GetBoard` in `client/boards.go` to parse these additional `included` fields from the API response
- Update `ListCardsByBoard` to enrich card output with label names (via `cardLabels` join table + `labels`)
## Capabilities
### New Capabilities
_None — this enhances existing capabilities._
### Modified Capabilities
- `api-client`: `GetBoard` SHALL parse `labels`, `cardLabels`, and `cardMemberships` from the board response `included` data
- `card-operations`: `ListCardsByBoard` (card list --board) SHALL include label names on each card
## Impact
- **Code**: `model/types.go` (Board struct), `client/boards.go` (GetBoard parsing), `client/cards.go` (ListCardsByBoard enrichment)
- **API**: No new API calls — parsing data already returned by `GET /api/boards/:id`
- **Breaking changes**: None — new fields are additive; JSON output gains new fields but existing fields unchanged
@@ -0,0 +1,25 @@
## MODIFIED Requirements
### Requirement: Board operations
The client SHALL provide a method to get a single board by ID (`GET /boards/{id}`), list board actions (`GET /boards/{boardId}/actions`) with pagination support, create a board (`POST /projects/{projectId}/boards`), and delete a board (`DELETE /boards/{id}`). `GetBoard` SHALL parse the `included` object from the response and populate the Board model with `lists`, `cards`, `labels`, `cardLabels`, and `cardMemberships`.
#### Scenario: Get board
- **WHEN** `GetBoard` is called with a board ID
- **THEN** the client SHALL send `GET /boards/{id}` and return a Board model including its included lists, cards, labels, cardLabels, and cardMemberships
#### Scenario: Get board includes labels
- **WHEN** `GetBoard` is called and the board has labels defined
- **THEN** the returned Board SHALL contain a `Labels` slice with all board labels
#### Scenario: Get board includes card-label associations
- **WHEN** `GetBoard` is called and cards on the board have labels attached
- **THEN** the returned Board SHALL contain a `CardLabels` slice with all card-label associations
- **AND** each `CardLabel` entry SHALL contain `cardId` and `labelId` fields
#### Scenario: Get board includes card memberships
- **WHEN** `GetBoard` is called and cards on the board have members assigned
- **THEN** the returned Board SHALL contain a `CardMemberships` slice with all card-membership associations
#### Scenario: List board actions
- **WHEN** `ListBoardActions` is called with a board ID and limit
- **THEN** the client SHALL paginate through `GET /boards/{boardId}/actions` and return action items
@@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: Enriched board-level card listing
The system SHALL provide a `card list --board <id>` operation that returns all cards across all lists in a board, with each card enriched with the `listName` field and a `labels` field. The operation SHALL: (1) call `GET /boards/{id}` to retrieve the board and its included lists, cards, labels, and cardLabels, (2) build a card-to-label-names map by joining `cardLabels` with `labels`, (3) inject `listName` and `labels` into each card. The `labels` field SHALL be an array of label name strings. The `--limit` flag SHALL apply to the total number of cards returned across all lists.
#### Scenario: List all cards on a board
- **WHEN** `pcli card list --board <id>` is executed
- **THEN** the system SHALL return all cards from all lists in the board
- **AND** each card SHALL include a `listName` field with the name of its containing list
- **AND** each card SHALL include a `labels` field with an array of label names attached to that card
#### Scenario: Card with multiple labels
- **WHEN** a card has two labels ("bug" and "urgent") attached
- **THEN** the `labels` array for that card SHALL contain both "bug" and "urgent"
#### Scenario: Card with no labels
- **WHEN** a card has no labels attached
- **THEN** the `labels` array for that card SHALL be an empty array (not null)
#### Scenario: Board card listing with limit
- **WHEN** `pcli card list --board <id> --limit 10` is executed
- **THEN** the system SHALL return at most 10 cards total across all lists
- **AND** each card SHALL include the `listName` and `labels` fields
@@ -0,0 +1,15 @@
## 1. Model Changes
- [x] 1.1 Add `Labels []Label`, `CardLabels []CardLabel`, and `CardMemberships []CardMembership` fields to `Board` struct in `model/types.go` (with `json:",omitempty"`)
- [x] 1.2 Add `Labels []string` field to `CardWithList` struct in `model/types.go`
## 2. Client Changes
- [x] 2.1 Update `GetBoard` in `client/boards.go` to parse `labels`, `cardLabels`, and `cardMemberships` from `included` response
- [x] 2.2 Update `ListCardsByBoard` in `client/cards.go` to build label-name map from board's `CardLabels` and `Labels`, and populate `Labels` on each `CardWithList`
## 3. Verification
- [x] 3.1 Build and test `pcli board get <id>` — verify JSON output includes labels and cardLabels when present
- [x] 3.2 Test `pcli card list --board <id>` — verify each card includes a `labels` array
- [x] 3.3 Test card with no labels returns empty array (not null)
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+25
View File
@@ -109,6 +109,8 @@ func printTable(data any, w io.Writer) error {
return printBoardTable([]model.Board{*data}, tw)
case *model.Card:
return printCardTable([]model.Card{*data}, tw)
case *model.CardDetail:
return printCardDetailTable(data, tw)
case *model.Comment:
return printCommentTable([]model.Comment{*data}, tw)
case *model.TaskList:
@@ -164,6 +166,29 @@ func printCardWithListTable(cards []model.CardWithList, tw *tabwriter.Writer) er
return nil
}
func printCardDetailTable(card *model.CardDetail, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tNAME\tLIST_ID\tTYPE\tCLOSED")
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%v\n", card.ID, card.Name, card.ListID, card.Type, card.IsClosed)
if len(card.TaskLists) > 0 {
fmt.Fprintln(tw)
fmt.Fprintln(tw, "TASK_LIST_ID\tTASK_LIST_NAME\tPOSITION")
for _, tl := range card.TaskLists {
fmt.Fprintf(tw, "%s\t%s\t%.0f\n", tl.ID, tl.Name, tl.Position)
}
}
if len(card.Tasks) > 0 {
fmt.Fprintln(tw)
fmt.Fprintln(tw, "TASK_ID\tTASK_NAME\tTASK_LIST_ID\tCOMPLETED")
for _, t := range card.Tasks {
fmt.Fprintf(tw, "%s\t%s\t%s\t%v\n", t.ID, t.Name, t.TaskListID, t.IsCompleted)
}
}
return nil
}
func printCommentTable(comments []model.Comment, tw *tabwriter.Writer) error {
fmt.Fprintln(tw, "ID\tCARD_ID\tTEXT\tCREATED_AT")
for _, c := range comments {