feat(sync): implement kanban-project-sync script with concurrency control and background execution
This commit is contained in:
+28
-265
@@ -1,282 +1,45 @@
|
||||
---
|
||||
description: Reconcile Planka board state with OpenSpec changes
|
||||
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion
|
||||
allowed-tools: Bash, Read
|
||||
---
|
||||
|
||||
# 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.
|
||||
Runs the `kanban-project-sync` script to reconcile Planka board state with OpenSpec changes.
|
||||
|
||||
## Prerequisites
|
||||
## How It Works
|
||||
|
||||
Before syncing, verify connectivity:
|
||||
The sync is handled by the `kanban-project-sync` bash script (on PATH). It:
|
||||
1. Checks Planka connectivity
|
||||
2. Bootstraps project/board/lists/label infrastructure (idempotent)
|
||||
3. Reads OpenSpec state and maps changes to board lists
|
||||
4. Creates/moves/updates Planka cards and task checklists
|
||||
5. Moves orphaned cards to Done
|
||||
|
||||
**OpenSpec is the source of truth.** Planka is a read-only projection. Sync is one-directional (OpenSpec -> Planka) and idempotent.
|
||||
|
||||
## Running the Sync
|
||||
|
||||
Read project config and invoke the script in background mode:
|
||||
|
||||
```bash
|
||||
pcli status
|
||||
PROJECT_NAME=$(yq -r '.planka.project' project.yaml)
|
||||
BOARD_NAME=$(yq -r '.planka.board' project.yaml)
|
||||
kanban-project-sync --project "$PROJECT_NAME" --board "$BOARD_NAME" --background
|
||||
```
|
||||
|
||||
If this fails, skip the Planka sync silently. Planka sync is best-effort - never block agentic work because Planka is unavailable.
|
||||
The `--background` flag makes the script fire-and-forget — it detaches and logs to `/tmp/kanban-project-sync-<project>-<board>.log`.
|
||||
|
||||
---
|
||||
## Concurrency
|
||||
|
||||
## Core Principles
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## When This Runs
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## Board Structure
|
||||
|
||||
The board lists map to OpenSpec lifecycle stages:
|
||||
|
||||
| List | Position | Purpose |
|
||||
|------|----------|---------|
|
||||
| **Backlog** | 1 | Non-agentic work items (human-managed, read-write) |
|
||||
| **To Do** | 2 | Non-agentic work items ready to start (human-managed, read-write) |
|
||||
| **Planning** | 3 | OpenSpec changes with artifacts still being created (`opsx:new`, `opsx:continue`, `opsx:ff`) |
|
||||
| **In Progress** | 4 | Active implementation — tasks being worked (`opsx:apply`) |
|
||||
| **Review** | 5 | All tasks complete, awaiting verification (`opsx:verify`) |
|
||||
| **Done** | 6 | Completed and archived (`opsx:archive`) |
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap: Ensure Project, Board, Lists, and Label Exist
|
||||
|
||||
Before reconciling, ensure all required Planka infrastructure exists. This makes the sync self-bootstrapping — running it on a fresh Planka instance will create everything needed.
|
||||
|
||||
### 1. Read project config
|
||||
|
||||
```bash
|
||||
PROJECT_NAME=$(yq '.planka.project' project.yaml)
|
||||
BOARD_NAME=$(yq '.planka.board' project.yaml)
|
||||
```
|
||||
|
||||
If `project.yaml` doesn't exist or has no `planka` section, ask the user for the project and board name, then offer to create the file.
|
||||
|
||||
### 2. Find or create the project
|
||||
|
||||
```bash
|
||||
PROJECT_ID=$(pcli project list | jq -r --arg name "$PROJECT_NAME" '.data[] | select(.name == $name) | .id')
|
||||
```
|
||||
|
||||
If no project found:
|
||||
```bash
|
||||
PROJECT_ID=$(pcli project create --name "$PROJECT_NAME" --type "public" | jq -r '.data.id')
|
||||
```
|
||||
|
||||
### 3. Find or create the board
|
||||
|
||||
```bash
|
||||
# Get project details to find boards
|
||||
BOARD_ID=$(pcli board list --project "$PROJECT_NAME" | jq -r --arg name "$BOARD_NAME" '.data[] | select(.name == $name) | .id')
|
||||
```
|
||||
|
||||
If no board found:
|
||||
```bash
|
||||
BOARD_ID=$(pcli board create --project $PROJECT_ID --name "$BOARD_NAME" | jq -r '.data.id')
|
||||
```
|
||||
|
||||
### 4. Find or create the lists
|
||||
|
||||
After obtaining the board, get its current lists:
|
||||
```bash
|
||||
EXISTING_LISTS=$(pcli board get $BOARD_ID | jq -r '.data.lists[]? | .name')
|
||||
```
|
||||
|
||||
Create any missing lists with explicit positions to maintain correct ordering:
|
||||
```bash
|
||||
# Only create lists that don't already exist
|
||||
pcli list create --board $BOARD_ID --name "Backlog" --position 65536
|
||||
pcli list create --board $BOARD_ID --name "To Do" --position 131072
|
||||
pcli list create --board $BOARD_ID --name "Planning" --position 196608
|
||||
pcli list create --board $BOARD_ID --name "In Progress" --position 262144
|
||||
pcli list create --board $BOARD_ID --name "Review" --position 327680
|
||||
pcli list create --board $BOARD_ID --name "Done" --position 393216
|
||||
```
|
||||
|
||||
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.labels[]? | select(.name == "agent") | .id')
|
||||
```
|
||||
|
||||
If no `agent` label found:
|
||||
```bash
|
||||
LABEL_ID=$(pcli label create --board $BOARD_ID --name "agent" --color "berry-red" | jq -r '.data.id')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reconciliation Steps
|
||||
|
||||
### 1. Gather OpenSpec state
|
||||
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This returns all active changes with their names, schemas, and status.
|
||||
|
||||
For each active change:
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
|
||||
Parse to get:
|
||||
- Change name
|
||||
- Schema name
|
||||
- Artifact completion status (how many artifacts complete vs total)
|
||||
- Whether all `applyRequires` artifacts are done
|
||||
- Whether tasks exist and their completion state
|
||||
|
||||
If a `tasks.md` exists, read it and parse checkbox state (`- [ ]` vs `- [x]`).
|
||||
|
||||
### 2. Determine target list for each change
|
||||
|
||||
Map each change to the correct board list based on its OpenSpec state:
|
||||
|
||||
| Condition | Target List |
|
||||
|-----------|-------------|
|
||||
| Artifacts incomplete (not all `applyRequires` done) | **Planning** |
|
||||
| Artifacts complete, tasks exist with incomplete items | **In Progress** |
|
||||
| All tasks complete (all `[x]`) | **Review** |
|
||||
| Change archived (not in active list) | **Done** |
|
||||
|
||||
### 3. Gather Planka state
|
||||
|
||||
```bash
|
||||
pcli card list --board $BOARD_ID | jq '.data[] | select(.labels[]?.name == "agent")'
|
||||
```
|
||||
|
||||
Build a map of existing agent-labelled cards by name, including which list they're currently in.
|
||||
|
||||
### 4. Reconcile: create missing cards
|
||||
|
||||
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.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
|
||||
pcli card add-label $CARD_ID --label $LABEL_ID
|
||||
```
|
||||
|
||||
### 5. Reconcile: move cards to correct list
|
||||
|
||||
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.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
|
||||
|
||||
### 6. Reconcile: sync task lists
|
||||
|
||||
For each OpenSpec change that has a `tasks.md`:
|
||||
|
||||
First, check if the card already has task lists by using `pcli card get`:
|
||||
|
||||
```bash
|
||||
# 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 (in order), assign incrementing positions:
|
||||
# position = (index + 1) * 65536 (i.e. 65536, 131072, 196608, ...)
|
||||
# - If a task with the same name already exists, update its completion state if needed
|
||||
# - If no matching task exists, create it with explicit position
|
||||
pcli task create --task-list $TL_ID --name "<task description>" --position <pos>
|
||||
|
||||
# For tasks already in Planka, update completion state to match tasks.md:
|
||||
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"
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
### 8. Report
|
||||
|
||||
After reconciliation, briefly summarise what changed:
|
||||
- Infrastructure created (project/board/lists/label): yes/no
|
||||
- Cards created: N
|
||||
- Cards moved: N (list details)
|
||||
- Tasks synced: N
|
||||
- Cards moved to Done: N
|
||||
- Errors (if any): list them
|
||||
|
||||
---
|
||||
|
||||
## Non-Agentic Work
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## ID Discovery
|
||||
|
||||
Planka IDs cannot be cached across sessions. Each sync run must discover IDs dynamically:
|
||||
|
||||
1. Read project and board name from `project.yaml`
|
||||
2. Find the project by name: `pcli project list | jq ...`
|
||||
3. Find the board by name within the project
|
||||
4. Find lists on the board: `pcli board get <board-id> | jq ...`
|
||||
5. Find agent cards: `pcli card list --board <board-id> | jq '.data[] | select(.labels[]?.name == "agent")'`
|
||||
6. Match cards to changes by name
|
||||
|
||||
---
|
||||
The script handles its own concurrency:
|
||||
- Uses `flock` to ensure only one sync runs per project-board pair
|
||||
- If a sync is already running, sets a pending flag and exits immediately
|
||||
- The running sync re-runs after completion if the pending flag is set
|
||||
- Multiple pending requests coalesce into a single re-run
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Never modify an `agent`-labelled card outside of this sync workflow**
|
||||
- **Never read Planka to determine what work to do** - query OpenSpec instead
|
||||
- **Always discover IDs dynamically** - never hardcode or cache across sessions
|
||||
- **Sync failures are silent** - log a warning but never block opsx workflows
|
||||
- **One board per project** - if multiple boards exist, ask the user which to sync to
|
||||
- **Idempotent** - safe to run multiple times, will not create duplicates
|
||||
- **Bootstrap is safe** - creating project/board/lists is idempotent; existing resources are reused
|
||||
- Sync is **best-effort** — if Planka is unreachable or the script fails, log a warning and continue
|
||||
- Never block agentic work because of sync
|
||||
- If `kanban-project-sync` is not on PATH, log a warning and skip
|
||||
|
||||
@@ -124,6 +124,20 @@ Board details include lists directly in `.data.lists[]`, not in an `included` se
|
||||
pcli board get <board-id> | jq '.data.lists[] | {id, name, position}'
|
||||
```
|
||||
|
||||
### Card List Labels
|
||||
Card list returns labels as **plain strings**, not objects:
|
||||
```bash
|
||||
# Labels are strings like "agent", NOT objects like {name: "agent"}
|
||||
pcli card list --board <board-id> | jq '.data[] | select(.labels[]? == "agent")'
|
||||
```
|
||||
|
||||
### Card Get Response
|
||||
Card get includes `taskLists` and `tasks` arrays (when they exist):
|
||||
```bash
|
||||
pcli card get <card-id> | jq '.data.taskLists[0].id'
|
||||
pcli card get <card-id> | jq '.data.tasks[] | {name, isCompleted}'
|
||||
```
|
||||
|
||||
### Finding Boards in a Project
|
||||
Use `board list --project` to find boards by project name:
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user