skills/project-board-enforcement/SKILL.md
MANDATORY for all work - the project board is THE source of truth. This skill provides verification functions and gates that other skills MUST call. No work proceeds without project board compliance.
npx skillsauth add troykelly/codex-skills project-board-enforcementInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The GitHub Project board is THE source of truth for all work state. Not labels. Not comments. Not memory. The project board.
Core principle: If it's not in the project board with correct fields, it doesn't exist.
This skill is called by other skills at gate points. It is not invoked directly.
Every issue, epic, and initiative MUST be in the project board BEFORE work begins.
This is not optional. This is not a suggestion. This is a hard gate.
# These MUST be set. Work cannot proceed without them.
echo $GITHUB_PROJECT # Full URL: https://github.com/users/USER/projects/N
echo $GITHUB_PROJECT_NUM # Just the number: N
echo $GH_PROJECT_OWNER # Owner: @me or org name
# If GITHUB_PROJECT is set, derive missing values automatically:
if [ -z "$GITHUB_PROJECT_NUM" ] && [ -n "$GITHUB_PROJECT" ]; then
NUM_CANDIDATE=$(echo "$GITHUB_PROJECT" | sed -E 's#.*/projects/([0-9]+).*#\1#')
if [ -n "$NUM_CANDIDATE" ] && [ "$NUM_CANDIDATE" != "$GITHUB_PROJECT" ]; then
export GITHUB_PROJECT_NUM="$NUM_CANDIDATE"
echo "Derived GITHUB_PROJECT_NUM=$GITHUB_PROJECT_NUM from GITHUB_PROJECT"
fi
fi
if [ -z "$GH_PROJECT_OWNER" ] && [ -n "$GITHUB_OWNER" ]; then
export GH_PROJECT_OWNER="$GITHUB_OWNER"
echo "Derived GH_PROJECT_OWNER=$GH_PROJECT_OWNER from GITHUB_OWNER"
fi
if [ -z "$GH_PROJECT_OWNER" ] && [ -n "$GITHUB_PROJECT" ]; then
OWNER_CANDIDATE=$(echo "$GITHUB_PROJECT" | sed -E 's#https://github.com/(orgs|users)/([^/]+)/projects/[0-9]+#\2#')
if [ -n "$OWNER_CANDIDATE" ] && [ "$OWNER_CANDIDATE" != "$GITHUB_PROJECT" ]; then
export GH_PROJECT_OWNER="$OWNER_CANDIDATE"
echo "Derived GH_PROJECT_OWNER=$GH_PROJECT_OWNER from GITHUB_PROJECT"
fi
fi
if [ -z "$GH_PROJECT_OWNER" ]; then
REMOTE_URL=$(git remote get-url origin 2>/dev/null || true)
OWNER_CANDIDATE=$(echo "$REMOTE_URL" | sed -E 's#(git@|https://)github.com[:/]+([^/]+)/[^/]+(\.git)?#\2#')
if [ -n "$OWNER_CANDIDATE" ] && [ "$OWNER_CANDIDATE" != "$REMOTE_URL" ]; then
export GH_PROJECT_OWNER="$OWNER_CANDIDATE"
echo "Derived GH_PROJECT_OWNER=$GH_PROJECT_OWNER from git remote"
fi
fi
If any are missing, stop and configure them before proceeding.
Every project MUST have these fields configured:
| Field | Type | Required Values | |-------|------|-----------------| | Status | Single select | Backlog, Ready, In Progress, In Review, Done, Blocked | | Type (or Issue Type) | Single select | Feature, Bug, Chore, Research, Spike, Epic, Initiative | | Priority | Single select | Critical, High, Medium, Low |
| Field | Type | Purpose | |-------|------|---------| | Verification | Single select | Not Verified, Failing, Partial, Passing | | Criteria Met | Number | Count of completed acceptance criteria | | Criteria Total | Number | Total acceptance criteria | | Last Verified | Date | When verification last ran | | Epic | Text | Parent epic issue number | | Initiative | Text | Parent initiative issue number |
GATE FUNCTION - Called before any work begins.
verify_issue_in_project() {
local issue=$1
# Get project item ID
ITEM_ID=$(gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json 2>/dev/null | \
jq -r ".items[] | select(.content.number == $issue) | .id")
if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then
echo "BLOCKED: Issue #$issue is not in the project board."
echo ""
echo "Add it with:"
echo " gh project item-add $GITHUB_PROJECT_NUM --owner $GH_PROJECT_OWNER --url \$(gh issue view $issue --json url -q .url)"
return 1
fi
echo "$ITEM_ID"
return 0
}
GATE FUNCTION - Called before work proceeds past issue check.
verify_status_set() {
local issue=$1
local item_id=$2
# Get current status
STATUS=$(gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json 2>/dev/null | \
jq -r ".items[] | select(.id == \"$item_id\") | .status.name")
if [ -z "$STATUS" ] || [ "$STATUS" = "null" ]; then
echo "BLOCKED: Issue #$issue has no Status set in project board."
echo ""
echo "Set status before proceeding."
return 1
fi
echo "$STATUS"
return 0
}
Called by issue-prerequisite after issue creation.
add_issue_to_project() {
local issue_url=$1
# Add to project
gh project item-add "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" --url "$issue_url"
if [ $? -ne 0 ]; then
echo "ERROR: Failed to add issue to project."
return 1
fi
# Get the item ID
local issue_num=$(echo "$issue_url" | grep -oE '[0-9]+$')
ITEM_ID=$(gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r ".items[] | select(.content.number == $issue_num) | .id")
echo "$ITEM_ID"
return 0
}
Called at every status transition.
set_project_status() {
local item_id=$1
local new_status=$2 # Backlog, Ready, In Progress, In Review, Done, Blocked
# Get project ID and field IDs (cache these in practice)
PROJECT_ID=$(gh project list --owner "$GH_PROJECT_OWNER" --format json | \
jq -r ".projects[] | select(.number == $GITHUB_PROJECT_NUM) | .id")
STATUS_FIELD_ID=$(gh project field-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r '.fields[] | select(.name == "Status") | .id')
OPTION_ID=$(gh project field-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r ".fields[] | select(.name == \"Status\") | .options[] | select(.name == \"$new_status\") | .id")
if [ -z "$OPTION_ID" ] || [ "$OPTION_ID" = "null" ]; then
echo "ERROR: Status '$new_status' not found in project."
return 1
fi
gh project item-edit --project-id "$PROJECT_ID" --id "$item_id" \
--field-id "$STATUS_FIELD_ID" --single-select-option-id "$OPTION_ID"
return $?
}
Called when creating issues.
set_project_type() {
local item_id=$1
local type=$2 # Feature, Bug, Chore, Research, Spike, Epic, Initiative
PROJECT_ID=$(gh project list --owner "$GH_PROJECT_OWNER" --format json | \
jq -r ".projects[] | select(.number == $GITHUB_PROJECT_NUM) | .id")
TYPE_FIELD_NAME="Type"
if ! gh project field-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" --format json | jq -e '.fields[] | select(.name == "Type")' >/dev/null 2>&1; then
if gh project field-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" --format json | jq -e '.fields[] | select(.name == "Issue Type")' >/dev/null 2>&1; then
TYPE_FIELD_NAME="Issue Type"
fi
fi
TYPE_FIELD_ID=$(gh project field-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r --arg type_field "$TYPE_FIELD_NAME" '.fields[] | select(.name == $type_field) | .id')
OPTION_ID=$(gh project field-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r --arg type_field "$TYPE_FIELD_NAME" --arg type_value "$type" '.fields[] | select(.name == $type_field) | .options[] | select(.name == $type_value) | .id')
gh project item-edit --project-id "$PROJECT_ID" --id "$item_id" \
--field-id "$TYPE_FIELD_ID" --single-select-option-id "$OPTION_ID"
}
USE THIS instead of label queries.
get_issues_by_status() {
local status=$1 # Ready, In Progress, etc.
gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r ".items[] | select(.status.name == \"$status\") | .content.number"
}
# Examples:
# get_issues_by_status "Ready"
# get_issues_by_status "In Progress"
# get_issues_by_status "Blocked"
get_issues_by_type() {
local type=$1 # Epic, Feature, etc.
gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r ".items[] | select(.type.name == \"$type\") | .content.number"
}
get_epic_children() {
local epic_num=$1
gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r ".items[] | select(.epic == \"#$epic_num\") | .content.number"
}
count_by_status() {
local status=$1
gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq "[.items[] | select(.status.name == \"$status\")] | length"
}
These are the points in workflows where project board verification is MANDATORY:
| Workflow Point | Gate | Skill | |----------------|------|-------| | Before any work | Issue in project | issue-driven-development Step 1 | | After issue creation | Add to project, set fields | issue-prerequisite | | Starting work | Status → In Progress | issue-driven-development Step 6 | | Creating branch | Verify project membership | branch-discipline | | PR created | Status → In Review | pr-creation | | Work complete | Status → Done | issue-driven-development completion | | Blocked | Status → Blocked | error-recovery | | Epic created | Add epic to project, set Type=Epic | epic-management | | Child issue created | Add to project, link to parent | issue-decomposition |
Backlog ──► Ready ──► In Progress ──► In Review ──► Done
│ │ │ │
│ │ │ │
└─────────┴────────────┴──────────────┴──► Blocked
│
▼
(any previous state)
validate_transition() {
local current=$1
local target=$2
case "$current→$target" in
"Backlog→Ready"|"Ready→In Progress"|"In Progress→In Review"|"In Review→Done")
return 0 ;;
*"→Blocked")
return 0 ;;
"Blocked→Backlog"|"Blocked→Ready"|"Blocked→In Progress")
return 0 ;;
*)
echo "Invalid transition: $current → $target"
return 1 ;;
esac
}
# WRONG - labels are NOT the source of truth
gh issue list --label "status:pending"
gh issue edit 123 --add-label "status:in-progress"
# RIGHT - project board IS the source of truth
get_issues_by_status "Ready"
set_project_status "$ITEM_ID" "In Progress"
Labels are still used for:
epic - Identifying epic issues (supplementary)epic-[name] - Grouping issues in an epic (supplementary)spawned-from:#N - Lineage tracking (supplementary)review-finding - Origin tracking (supplementary)But state (Ready, In Progress, Blocked, etc.) lives in the project board.
Run periodically to detect drift:
verify_project_sync() {
echo "## Project Board Sync Check"
echo ""
# Check for issues with branches but Status != In Progress
echo "### Issues with branches but not 'In Progress':"
for branch in $(git branch -r | grep -E 'feature/[0-9]+' | sed 's/.*feature\///' | cut -d- -f1); do
status=$(gh project item-list "$GITHUB_PROJECT_NUM" --owner "$GH_PROJECT_OWNER" \
--format json | \
jq -r ".items[] | select(.content.number == $branch) | .status.name")
if [ "$status" != "In Progress" ] && [ "$status" != "In Review" ]; then
echo "- #$branch: Status='$status' but has active branch"
fi
done
# Check for In Progress issues with no recent activity
echo ""
echo "### 'In Progress' issues with no recent commits:"
for issue in $(get_issues_by_status "In Progress"); do
branch=$(git branch -r | grep -E "feature/$issue-" | head -1)
if [ -z "$branch" ]; then
echo "- #$issue: In Progress but no branch exists"
fi
done
}
All project board errors should be clear and actionable:
project_error() {
local code=$1
local context=$2
case "$code" in
"NOT_IN_PROJECT")
echo "BLOCKED: Issue $context is not in the project board."
echo "Fix: gh project item-add $GITHUB_PROJECT_NUM --owner $GH_PROJECT_OWNER --url \$(gh issue view $context --json url -q .url)"
;;
"NO_STATUS")
echo "BLOCKED: Issue $context has no Status field set."
echo "Fix: Update the issue's Status field in the project board."
;;
"INVALID_TRANSITION")
echo "BLOCKED: Cannot transition $context - invalid state change."
;;
"PROJECT_NOT_FOUND")
echo "BLOCKED: Project $GITHUB_PROJECT_NUM not found or not accessible."
echo "Fix: Verify GITHUB_PROJECT_NUM and GH_PROJECT_OWNER are correct."
;;
esac
return 1
}
This skill is called by:
issue-driven-development - All status transitionsissue-prerequisite - After issue creationepic-management - Epic and child issue setupautonomous-orchestration - State queries and updatessession-start - Sync verificationwork-intake - Project readiness checkBefore proceeding past any gate:
data-ai
Defines behavior protocol for spawned worker agents. Injected into worker prompts. Covers startup, progress reporting, exit conditions, and handover preparation.
development
Defines context handover format when workers hit turn limit. Posts structured handover to GitHub issue comments enabling replacement workers to continue seamlessly.
data-ai
Use to spawn isolated worker processes for autonomous issue work. Creates git worktrees, constructs worker prompts, and handles worker lifecycle.
tools
Entry point for ALL work requests - triages scope from trivial to massive, asks clarifying questions, and routes to appropriate planning skills. Use this when receiving any new work request.