From bmad-skills
Bidirectionally syncs BMAD markdown planning artifacts (epics, stories, sprints) with GitHub Projects v2 using gh CLI and GraphQL. Useful for visual tracking and status updates across files and boards.
npx claudepluginhub bmad-labs/skills --plugin bmad-skillsThis skill uses the workspace's default tool permissions.
Bidirectional sync between BMAD planning artifacts (epics, stories, sprint plan) stored as
Suggests manual /compact at logical task boundaries in long Claude Code sessions and multi-phase tasks to avoid arbitrary auto-compaction losses.
Share bugs, ideas, or general feedback.
Bidirectional sync between BMAD planning artifacts (epics, stories, sprint plan) stored as markdown files and GitHub Projects v2 for visual project tracking.
Quick navigation
ghCLI commands, GraphQL queries →references/gh-commands.md- Issue body template, field mapping, labels, milestones →
references/content-mapping.md- Config file format →
references/config-schema.md- Artifact parser script →
scripts/parse-artifacts.py
Never sync automatically. Every mutating operation (create issues, update fields, modify files) requires generating a sync report first, presenting it to the user, and waiting for explicit approval before executing. The user must see exactly what will change.
Field-level source of truth. Each field has one owner — either BMAD files or GitHub. During sync, the owner side always wins. Never overwrite the owner's data.
Idempotent operations. Running the same sync twice produces the same result. Link-back
identifiers (H1 title links in story files, gh_item_url in planning frontmatter) prevent
duplicate creation. Always check for existing sync state before creating.
Never auto-parse issue bodies with a script. Issue body generation requires reading each
story file directly with the Read tool and crafting the body manually following the template
in references/content-mapping.md. Auto-parsing scripts fail silently in subtle ways:
they strip task lists when formatting assumptions are wrong, gut dev notes by removing code
blocks, and produce empty section headers. One bad push contaminates all 27 issues at once.
Read the file. Write the body. No shortcuts.
| User Says | Workflow | Config Required? |
|---|---|---|
| "set up github project", "onboard", "initialize github" | Onboarding | No (creates config) |
| "push to github", "sync push", "create issues" | Push | Yes |
| "pull from github", "sync pull", "update from github" | Pull | Yes |
| "sync status", "what's out of sync" | Status Check | Yes |
If no .github-sync.yaml exists at project root, always route to Onboarding first.
Before any workflow, verify these in order. Stop at first failure.
Step 1: gh --version
→ If missing: "Install GitHub CLI: https://cli.github.com/"
Step 2: gh auth status
→ If not authenticated: "Run: gh auth login"
Step 3: gh auth status (check for "project" in scopes)
→ If missing: "Run: gh auth refresh -s project"
Step 4: (For push/pull/status only) Check .github-sync.yaml exists
→ If missing: "Run onboarding first: say 'set up github project'"
This is the first-time setup. It creates or connects to a GitHub Project, creates custom fields, labels, and phase-based milestones.
Run the parser to discover what exists:
python3 .agents/skills/github-sync/scripts/parse-artifacts.py --mode scan \
--stories-dir .artifacts/implementation-artifacts
Also check that these files exist:
.artifacts/planning-artifacts/epics.md.artifacts/planning-artifacts/sprint-plan.mdNote: The artifacts path
.artifacts/may differ per project. Check the actual directory structure — some projects use_bmad-output/. Confirm the correct paths with the user and use them throughout. Updatepaths.*in the config accordingly.
Report the count: "Found N story files, M epics, K sprints."
Auto-detect the current repo:
gh repo view --json owner,name --jq '.owner.login + "/" + .name'
List existing projects and ask the user whether to use one or create new:
gh project list --owner {owner} --format json
Present detected owner/repo and existing projects. Ask for confirmation before proceeding.
Read references/content-mapping.md now — sections 7 (Label Scheme), 8 (Milestone Scheme),
and 12 (Epic Derivation). You need these to build an accurate onboarding report.
Then parse the actual artifacts:
python3 .agents/skills/github-sync/scripts/parse-artifacts.py --mode epics \
--file .artifacts/planning-artifacts/epics.md
python3 .agents/skills/github-sync/scripts/parse-artifacts.py --mode sprints \
--file .artifacts/planning-artifacts/sprint-plan.md
Present a report showing everything that WILL be created:
=== GITHUB SYNC — ONBOARDING REPORT ===
Repository: {owner}/{repo}
Project: #{N} "{title}" (existing) OR new project "{title}"
Will create/verify:
Custom fields (7):
- Story ID (TEXT)
- Epic (SINGLE_SELECT: 12 options, rainbow colors)
- Sprint (SINGLE_SELECT: Sprint 01–12, rainbow colors + sprint goal descriptions)
- Dev (SINGLE_SELECT: All, Dev 1, Dev 2, Dev 3, Dev 1+2)
- Sprint Start (DATE)
- Sprint End (DATE)
- Story Points (NUMBER)
Built-in fields to set:
- Start date (DATE, pre-existing) ← same dates as Sprint Start
- Target date (DATE, pre-existing) ← same dates as Sprint End
- Status (single-select, pre-existing)
{N} labels:
- Epic labels (one per epic, e.g., epic:1-foundation ... epic:12-testing)
- Phase labels (e.g., phase:poc, phase:hardening)
- Layer labels (optional, project-specific architecture layers)
{K} milestones (phase-based, NOT per-sprint):
- PoC (due: {sprint_6_end}) Sprints 1–6
- Production Hardening (due: {sprint_12_end}) Sprints 7–12
Note: No "Phase" custom field — the milestone covers phase membership.
Proceed with onboarding? [Y/N]
HALT HERE. Wait for user approval.
Read references/gh-commands.md now — you need the exact commands for every operation below.
If approved, execute in this order:
Critical: After calling
updateProjectV2Fieldto set colors/descriptions, the single-select option IDs CHANGE (the mutation replaces the options list). Always re-query option IDs after calling this mutation and use the new IDs in the config.
Read references/config-schema.md now — you need the exact YAML format.
Create .github-sync.yaml at project root with all populated IDs following that schema.
Note: No phase field in field_ids or option_ids — Phase was replaced by milestones.
Onboarding complete!
Project: https://github.com/orgs/{owner}/projects/{number}
Config: .github-sync.yaml
Next: Run "push stories to github" to create issues.
Pushes BMAD story files to GitHub as issues, adds them to the project, sets all fields, sets date fields, and establishes relationships.
Read .github-sync.yaml. Verify all field IDs are populated. If any are empty, re-query
field IDs from GitHub (the IDs may have been lost).
python3 .agents/skills/github-sync/scripts/parse-artifacts.py --mode scan \
--stories-dir .artifacts/implementation-artifacts
For each story, determine create vs edit mode:
# Story X.X: Title (no link) → CREATE# [Story X.X: Title](url) → UPDATE (already synced)If the user specified a filter (e.g., "push stories 1.1 1.2", "push epic 1", "push sprint 2"), filter the story list before proceeding. If no filter, push all stories.
⚠️ Rule 4 applies here: Read each file with the Read tool. Build bodies manually. No scripts.
For each story:
references/content-mapping.md — sections 1–4 and 9 for the exact format#N GitHub issue numbers=== GITHUB SYNC — PUSH REPORT ===
Generated: {timestamp}
--- CREATES ({count} new issues) ---
[CREATE] {story_id} {short_title}
File: {story_file_path}
Epic: {epic_label} | Sprint: {sprint} | Dev: {dev} | Phase: {phase}
Labels: epic:{N}-{name}, phase:{phase}
Milestone: {PoC | Production Hardening}
--- UPDATES ({count} existing issues) ---
[UPDATE] {story_id} {short_title} (#{issue_number})
Changes: body updated
--- SKIPPED ({count} items) ---
[SKIP] {story_id} {short_title} — already synced, no local changes
--- SUMMARY ---
Creates: {N} | Updates: {N} | Skips: {N}
Proceed? [Y/N]
HALT HERE. Wait for user approval.
If approved, for each CREATE:
/tmp/issue-{story_id}.md)gh issue create --title "..." --body-file /tmp/... --label "epic:X-name" --label "phase:poc" --milestone "{PoC|Production Hardening}"gh project item-add {project_number} --owner {owner} --url {issue_url} --format jsongh project item-edit:
# [Story X.X: Title](issue_url)For each UPDATE:
gh issue edit {number} --body-file /tmp/... --milestone "{phase_milestone}"After all issues are created, establish GitHub native relationships.
Read references/gh-commands.md section 12 for the exact GraphQL mutations.
Create one epic tracking issue per epic (if not already created). See
references/content-mapping.md section 10 for the body template. Then for each story,
set its epic tracking issue as its parent:
mutation {
addSubIssue(input: {
issueId: "{epic_tracking_issue_node_id}"
subIssueId: "{story_issue_node_id}"
replaceParent: true
}) {
issue { number }
subIssue { number }
}
}
Read the dependency table from sprint-plan.md (the Dependencies column in each sprint's
story table). For each dependency A → B (story A depends on story B):
mutation {
addBlockedBy(input: {
issueId: "{story_A_node_id}"
blockingIssueId: "{story_B_node_id}"
}) {
issue { number }
blockingIssue { number }
}
}
Always source dependencies from sprint-plan.md, not from story file bodies. The sprint plan is the authoritative dependency record.
Get issue node IDs via:
gh api graphql -f query='
query {
repository(owner:"{owner}", name:"{repo}") {
issues(first:50, orderBy:{field:CREATED_AT, direction:ASC}) {
nodes { number id title }
}
}
}
'
Push complete!
Created: {N} issues
Updated: {N} issues
Skipped: {N} items
Relationships set: {N} parent, {M} blocked-by
Config updated: last_synced = {timestamp}
Pulls status, assignees, and task completion from GitHub back into local BMAD files.
Read .github-sync.yaml. Then read references/gh-commands.md section 9 to get the full
GraphQL items query. Execute it to fetch all project items.
For each GitHub item that has a "Story ID" field value:
Status: line=== GITHUB SYNC — PULL REPORT ===
Generated: {timestamp}
--- FILE UPDATES ({count} items) ---
[UPDATE] {story_filename}.md
Status: ready-for-dev → done (from GitHub #{issue_number})
Assignee: (none) → @{username}
--- NO CHANGES ({count} items) ---
[OK] {story_filename}.md — in sync
--- GITHUB-ONLY ({count} items) ---
[WARN] GitHub #{N} "{title}" — no matching local file
--- SUMMARY ---
File updates: {N} | No changes: {N} | Warnings: {N}
Proceed? [Y/N]
HALT HERE. Wait for user approval.
If approved, for each file update:
Status: line with the mapped BMAD statuslast_syncedCompare local files with GitHub state without making any changes. No approval needed.
# Scan local files
python3 .agents/skills/github-sync/scripts/parse-artifacts.py --mode scan \
--stories-dir .artifacts/implementation-artifacts
# Query GitHub items (full field values)
# Use GraphQL query from references/gh-commands.md section 9
Output a comparison table:
=== GITHUB SYNC — STATUS ===
| Story | Local Status | GitHub Status | Sprint | Synced? | Issue |
|-------|--------------|---------------|--------|---------|-------|
| 1.1 | ready-for-dev | Done | 01 | NO | #1 |
| 1.2 | ready-for-dev | Ready | 01 | YES | #2 |
| 2.1 | backlog | (not synced) | 02 | NO | — |
Summary: {N} in sync, {M} out of sync, {K} not yet pushed
| Filter | Example | Effect |
|---|---|---|
| By story IDs | push stories 1.1 1.2 1.3 | Only these stories |
| By epic | push epic 3 | All stories in Epic 3 |
| By sprint | push sprint 2 | All stories assigned to Sprint 2 |
| By status | push unsynced | Only stories not yet pushed |
| All | push or push all | All stories (default) |
| Error | Action |
|---|---|
gh command returns non-zero exit code | Show the error output, suggest fix, halt |
| Field ID missing from config | Re-query field IDs via section 4 of gh-commands.md |
| Rate limit hit (HTTP 429) | Show warning, suggest waiting 60 seconds, halt |
| Story file has unexpected format | Skip with warning in the sync report |
| Milestone already exists | Skip creation (idempotent) |
| Label already exists | Skip creation (idempotent) |
| Issue already exists for story | Switch to update mode |
| Single-select option IDs stale | Re-query after any updateProjectV2Field call — the mutation replaces all options and issues new IDs |
addBlockedBy payload field error | Return field is blockingIssue (not blockedByIssue) |
updateProjectV2Field rejects projectId | This mutation takes only fieldId, not projectId |
updateProjectV2Field rejects option id | Options use name/color/description only — no id field |
| File | Read When |
|---|---|
references/gh-commands.md | You need exact gh CLI commands or GraphQL queries |
references/content-mapping.md | You need to build issue body, map fields, or check label/milestone scheme |
references/config-schema.md | You need to create or read .github-sync.yaml |