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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user