diff --git a/.claude/commands/mgw/issue.md b/.claude/commands/mgw/issue.md
index 5a9c3ac..91fab81 100644
--- a/.claude/commands/mgw/issue.md
+++ b/.claude/commands/mgw/issue.md
@@ -409,6 +409,74 @@ Also add branch cross-ref:
BRANCH=$(git branch --show-current)
```
Add to linked_branches if not main/master.
+
+After writing the state file, sync the board Status field (non-blocking):
+```bash
+# Board sync — update board Status field to reflect new pipeline_stage
+# Source the shared utility from board-sync.md, then call it
+# Reads REPO_ROOT from environment (set in validate_and_load / init_state)
+update_board_status() {
+ local ISSUE_NUMBER="$1"
+ local NEW_STAGE="$2"
+ if [ -z "$ISSUE_NUMBER" ] || [ -z "$NEW_STAGE" ]; then return 0; fi
+ BOARD_NODE_ID=$(python3 -c "
+import json,sys,os
+try:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ print(p.get('project',{}).get('project_board',{}).get('node_id',''))
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$BOARD_NODE_ID" ]; then return 0; fi
+ ITEM_ID=$(python3 -c "
+import json,sys
+try:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ for m in p.get('milestones',[]):
+ for i in m.get('issues',[]):
+ if i.get('github_number')==${ISSUE_NUMBER}:
+ print(i.get('board_item_id','')); sys.exit(0)
+ print('')
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$ITEM_ID" ]; then return 0; fi
+ FIELD_ID=$(python3 -c "
+import json,sys,os
+try:
+ s='${REPO_ROOT}/.mgw/board-schema.json'
+ if os.path.exists(s):
+ print(json.load(open(s)).get('fields',{}).get('status',{}).get('field_id',''))
+ else:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('field_id',''))
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$FIELD_ID" ]; then return 0; fi
+ OPTION_ID=$(python3 -c "
+import json,sys,os
+try:
+ stage='${NEW_STAGE}'
+ s='${REPO_ROOT}/.mgw/board-schema.json'
+ if os.path.exists(s):
+ print(json.load(open(s)).get('fields',{}).get('status',{}).get('options',{}).get(stage,''))
+ else:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('options',{}).get(stage,''))
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$OPTION_ID" ]; then return 0; fi
+ gh api graphql -f query='
+ mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$optionId:String!){
+ updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:{singleSelectOptionId:$optionId}}){projectV2Item{id}}
+ }
+ ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \
+ -f fieldId="$FIELD_ID" -f optionId="$OPTION_ID" 2>/dev/null || true
+}
+
+# Call after state file is written — non-blocking, never fails the pipeline
+update_board_status $ISSUE_NUMBER "$pipeline_stage"
+```
+
+See @~/.claude/commands/mgw/workflows/board-sync.md for the full utility and data source reference.
@@ -465,5 +533,6 @@ Consider closing or commenting on the issue with your reasoning.
- [ ] Passed issues get mgw:triaged label
- [ ] User confirms, overrides, or rejects
- [ ] State file written to .mgw/active/ (if accepted) with comment tracking fields and gate_result
+- [ ] Board Status field updated via update_board_status (non-blocking — failure does not block)
- [ ] Next steps offered
diff --git a/.claude/commands/mgw/run.md b/.claude/commands/mgw/run.md
index 7a6a780..0b14ee3 100644
--- a/.claude/commands/mgw/run.md
+++ b/.claude/commands/mgw/run.md
@@ -38,6 +38,7 @@ Checkpoints requiring user input:
@~/.claude/commands/mgw/workflows/github.md
@~/.claude/commands/mgw/workflows/gsd.md
@~/.claude/commands/mgw/workflows/validation.md
+@~/.claude/commands/mgw/workflows/board-sync.md
@@ -57,6 +58,66 @@ REPO_ROOT=$(git rev-parse --show-toplevel)
DEFAULT=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name)
```
+Define the board sync utility (non-blocking — see board-sync.md for full reference):
+```bash
+update_board_status() {
+ local ISSUE_NUMBER="$1"
+ local NEW_STAGE="$2"
+ if [ -z "$ISSUE_NUMBER" ] || [ -z "$NEW_STAGE" ]; then return 0; fi
+ BOARD_NODE_ID=$(python3 -c "
+import json,sys,os
+try:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ print(p.get('project',{}).get('project_board',{}).get('node_id',''))
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$BOARD_NODE_ID" ]; then return 0; fi
+ ITEM_ID=$(python3 -c "
+import json,sys
+try:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ for m in p.get('milestones',[]):
+ for i in m.get('issues',[]):
+ if i.get('github_number')==${ISSUE_NUMBER}:
+ print(i.get('board_item_id','')); sys.exit(0)
+ print('')
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$ITEM_ID" ]; then return 0; fi
+ FIELD_ID=$(python3 -c "
+import json,sys,os
+try:
+ s='${REPO_ROOT}/.mgw/board-schema.json'
+ if os.path.exists(s):
+ print(json.load(open(s)).get('fields',{}).get('status',{}).get('field_id',''))
+ else:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('field_id',''))
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$FIELD_ID" ]; then return 0; fi
+ OPTION_ID=$(python3 -c "
+import json,sys,os
+try:
+ stage='${NEW_STAGE}'
+ s='${REPO_ROOT}/.mgw/board-schema.json'
+ if os.path.exists(s):
+ print(json.load(open(s)).get('fields',{}).get('status',{}).get('options',{}).get(stage,''))
+ else:
+ p=json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('status',{}).get('options',{}).get(stage,''))
+except: print('')
+" 2>/dev/null || echo "")
+ if [ -z "$OPTION_ID" ]; then return 0; fi
+ gh api graphql -f query='
+ mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$optionId:String!){
+ updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:{singleSelectOptionId:$optionId}}){projectV2Item{id}}
+ }
+ ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \
+ -f fieldId="$FIELD_ID" -f optionId="$OPTION_ID" 2>/dev/null || true
+}
+```
+
Parse $ARGUMENTS for issue number. If missing:
```
AskUserQuestion(
@@ -248,7 +309,7 @@ Return ONLY valid JSON:
|---------------|--------|
| **informational** | Log: "MGW: ${NEW_COUNT} new comment(s) reviewed — informational, continuing." Update `triage.last_comment_count` in state file. Continue pipeline. |
| **material** | Log: "MGW: Material comment(s) detected — scope may have changed." Update state: add new_requirements to triage context. Update `triage.last_comment_count`. Re-read issue body for updated requirements. Continue with enriched context (pass new_requirements to planner). Check for security keywords in material comments (see below). |
-| **blocking** | Log: "MGW: Blocking comment detected — pipeline paused." Update state: `pipeline_stage = "blocked"`. Apply mgw:blocked label. Post comment on issue: `> **MGW** . \`pipeline-blocked\` . Blocked by stakeholder comment. Reason: ${blocking_reason}`. Stop pipeline execution. |
+| **blocking** | Log: "MGW: Blocking comment detected — pipeline paused." Update state: `pipeline_stage = "blocked"`. Apply mgw:blocked label. Call `update_board_status $ISSUE_NUMBER "blocked"` (non-blocking). Post comment on issue: `> **MGW** . \`pipeline-blocked\` . Blocked by stakeholder comment. Reason: ${blocking_reason}`. Stop pipeline execution. |
**Security keyword check for material comments:**
```bash
@@ -339,6 +400,9 @@ Log comment in state file (at `${REPO_ROOT}/.mgw/active/`).
Only run this step if gsd_route is "gsd:quick" or "gsd:quick --full".
Update pipeline_stage to "executing" in state file (at `${REPO_ROOT}/.mgw/active/`).
+```bash
+update_board_status $ISSUE_NUMBER "executing" # non-blocking board sync
+```
Determine flags:
- "gsd:quick" → $QUICK_FLAGS = ""
@@ -539,6 +603,9 @@ node ~/.claude/get-shit-done/bin/gsd-tools.cjs commit "docs(quick-${next_num}):
```
Update state (at `${REPO_ROOT}/.mgw/active/`): gsd_artifacts.path = $QUICK_DIR, pipeline_stage = "verifying".
+```bash
+update_board_status $ISSUE_NUMBER "verifying" # non-blocking board sync
+```
@@ -576,6 +643,7 @@ Set pipeline_stage to "discussing" and apply "mgw:discussing" label:
```bash
gh issue edit ${ISSUE_NUMBER} --remove-label "mgw:in-progress" 2>/dev/null
gh issue edit ${ISSUE_NUMBER} --add-label "mgw:discussing" 2>/dev/null
+update_board_status $ISSUE_NUMBER "discussing" # non-blocking board sync
```
Present to user:
@@ -623,6 +691,9 @@ If proceed: apply "mgw:approved" label and continue.
```
Update pipeline_stage to "planning" (at `${REPO_ROOT}/.mgw/active/`).
+ ```bash
+ update_board_status $ISSUE_NUMBER "planning" # non-blocking board sync
+ ```
2. **If resuming with pipeline_stage = "planning" and ROADMAP.md exists:**
Discover phases from ROADMAP and run the full per-phase GSD lifecycle:
@@ -785,6 +856,9 @@ COMMENTEOF
```
After ALL phases complete → update pipeline_stage to "verifying" (at `${REPO_ROOT}/.mgw/active/`).
+ ```bash
+ update_board_status $ISSUE_NUMBER "verifying" # non-blocking board sync
+ ```
@@ -822,6 +896,9 @@ gh issue comment ${ISSUE_NUMBER} --body "$EXEC_BODY" 2>/dev/null || true
```
Update pipeline_stage to "pr-pending" (at `${REPO_ROOT}/.mgw/active/`).
+```bash
+update_board_status $ISSUE_NUMBER "pr-created" # non-blocking board sync (pr-pending maps to pr-created on board)
+```
@@ -992,6 +1069,10 @@ Update state (at `${REPO_ROOT}/.mgw/active/`):
- linked_pr = PR number
- pipeline_stage = "pr-created"
+```bash
+update_board_status $ISSUE_NUMBER "pr-created" # non-blocking board sync
+```
+
Add cross-ref (at `${REPO_ROOT}/.mgw/cross-refs.json`): issue → PR.
@@ -1052,6 +1133,9 @@ gh issue comment ${ISSUE_NUMBER} --body "$PR_READY_BODY" 2>/dev/null || true
```
Update pipeline_stage to "done" (at `${REPO_ROOT}/.mgw/active/`).
+```bash
+update_board_status $ISSUE_NUMBER "done" # non-blocking board sync
+```
Report to user:
```
@@ -1092,5 +1176,7 @@ Next:
- [ ] Worktree cleaned up, user returned to main workspace
- [ ] mgw:in-progress label removed at completion
- [ ] State file updated through all pipeline stages
+- [ ] Board Status field synced at each pipeline_stage transition (non-blocking)
+- [ ] Board sync failures never block pipeline execution
- [ ] User prompted to run /mgw:sync after merge
diff --git a/.claude/commands/mgw/workflows/board-sync.md b/.claude/commands/mgw/workflows/board-sync.md
new file mode 100644
index 0000000..662a391
--- /dev/null
+++ b/.claude/commands/mgw/workflows/board-sync.md
@@ -0,0 +1,192 @@
+
+Shared board sync utility for MGW pipeline commands. Called after any pipeline_stage
+transition to update the corresponding board item's Status field via GitHub Projects v2
+GraphQL API.
+
+All board updates are non-blocking: if the board is not configured, if the issue has no
+board_item_id, or if the API call fails, the function returns silently. A board sync
+failure MUST NEVER block pipeline execution.
+
+
+## update_board_status
+
+Call this function after any `pipeline_stage` transition in any MGW command.
+
+```bash
+# update_board_status — Update board Status field after a pipeline_stage transition
+# Args: ISSUE_NUMBER, NEW_PIPELINE_STAGE
+# Non-blocking: all failures are silent no-ops
+update_board_status() {
+ local ISSUE_NUMBER="$1"
+ local NEW_STAGE="$2"
+
+ if [ -z "$ISSUE_NUMBER" ] || [ -z "$NEW_STAGE" ]; then
+ return 0
+ fi
+
+ # Read board project node ID from project.json (non-blocking — if not configured, skip)
+ BOARD_NODE_ID=$(python3 -c "
+import json, sys
+try:
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ print(p.get('project', {}).get('project_board', {}).get('node_id', ''))
+except:
+ print('')
+" 2>/dev/null || echo "")
+ if [ -z "$BOARD_NODE_ID" ]; then return 0; fi
+
+ # Get board_item_id for this issue from project.json
+ ITEM_ID=$(python3 -c "
+import json, sys
+try:
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ for m in p.get('milestones', []):
+ for i in m.get('issues', []):
+ if i.get('github_number') == ${ISSUE_NUMBER}:
+ print(i.get('board_item_id', ''))
+ sys.exit(0)
+ print('')
+except:
+ print('')
+" 2>/dev/null || echo "")
+ if [ -z "$ITEM_ID" ]; then return 0; fi
+
+ # Map pipeline_stage to Status field option ID
+ # Reads from board-schema.json first, falls back to project.json fields
+ FIELD_ID=$(python3 -c "
+import json, sys, os
+try:
+ schema_path = '${REPO_ROOT}/.mgw/board-schema.json'
+ if os.path.exists(schema_path):
+ s = json.load(open(schema_path))
+ print(s.get('fields', {}).get('status', {}).get('field_id', ''))
+ else:
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ fields = p.get('project', {}).get('project_board', {}).get('fields', {})
+ print(fields.get('status', {}).get('field_id', ''))
+except:
+ print('')
+" 2>/dev/null || echo "")
+ if [ -z "$FIELD_ID" ]; then return 0; fi
+
+ OPTION_ID=$(python3 -c "
+import json, sys, os
+try:
+ stage = '${NEW_STAGE}'
+ schema_path = '${REPO_ROOT}/.mgw/board-schema.json'
+ if os.path.exists(schema_path):
+ s = json.load(open(schema_path))
+ options = s.get('fields', {}).get('status', {}).get('options', {})
+ print(options.get(stage, ''))
+ else:
+ p = json.load(open('${REPO_ROOT}/.mgw/project.json'))
+ fields = p.get('project', {}).get('project_board', {}).get('fields', {})
+ options = fields.get('status', {}).get('options', {})
+ print(options.get(stage, ''))
+except:
+ print('')
+" 2>/dev/null || echo "")
+ if [ -z "$OPTION_ID" ]; then return 0; fi
+
+ # Update the Status field on the board item (non-blocking)
+ gh api graphql -f query='
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
+ updateProjectV2ItemFieldValue(input: {
+ projectId: $projectId
+ itemId: $itemId
+ fieldId: $fieldId
+ value: { singleSelectOptionId: $optionId }
+ }) { projectV2Item { id } }
+ }
+ ' -f projectId="$BOARD_NODE_ID" \
+ -f itemId="$ITEM_ID" \
+ -f fieldId="$FIELD_ID" \
+ -f optionId="$OPTION_ID" 2>/dev/null || true
+}
+```
+
+## Stage-to-Status Mapping
+
+The Status field options correspond to pipeline_stage values:
+
+| pipeline_stage | Board Status Option |
+|----------------|-------------------|
+| `new` | New |
+| `triaged` | Triaged |
+| `needs-info` | Needs Info |
+| `needs-security-review` | Needs Security Review |
+| `discussing` | Discussing |
+| `approved` | Approved |
+| `planning` | Planning |
+| `executing` | Executing |
+| `verifying` | Verifying |
+| `pr-created` | PR Created |
+| `done` | Done |
+| `failed` | Failed |
+| `blocked` | Blocked |
+
+Option IDs for each stage are looked up at runtime from:
+1. `.mgw/board-schema.json` → `fields.status.options.` (preferred)
+2. `.mgw/project.json` → `project.project_board.fields.status.options.` (fallback)
+
+## Data Sources
+
+| Field | Source |
+|-------|--------|
+| `BOARD_NODE_ID` | `project.json` → `project.project_board.node_id` |
+| `ITEM_ID` | `project.json` → `milestones[*].issues[*].board_item_id` (set by #73) |
+| `FIELD_ID` | `board-schema.json` or `project.json` → `fields.status.field_id` |
+| `OPTION_ID` | `board-schema.json` or `project.json` → `fields.status.options.` |
+
+## Non-Blocking Contract
+
+Every failure case returns 0 (success) without printing to stderr. The caller is never
+aware of board sync failures. This guarantees:
+
+- Board not configured (no `node_id` in project.json) → silent no-op
+- Issue has no `board_item_id` → silent no-op (not yet added to board)
+- Status field not configured → silent no-op
+- Stage has no mapped option ID → silent no-op
+- GraphQL API error → silent no-op (`|| true` suppresses exit code)
+- Network error → silent no-op
+
+## Touch Points
+
+Source or inline this utility in any MGW command that writes `pipeline_stage`.
+Call `update_board_status` immediately after each stage transition write.
+
+### In issue.md (triage stage transitions)
+
+After writing `pipeline_stage` to the state file in the `write_state` step:
+```bash
+# After: pipeline_stage written to .mgw/active/.json
+update_board_status $ISSUE_NUMBER "$pipeline_stage" # non-blocking
+```
+
+Transitions in issue.md:
+- `needs-info` — validity or detail gate blocked
+- `needs-security-review` — security gate blocked
+- `triaged` — all gates passed or user override
+
+### In run.md (pipeline stage transitions)
+
+After each `pipeline_stage` checkpoint write to project.json and state file:
+```bash
+# After: pipeline_stage checkpoint written (state.md "Update Issue Pipeline Stage" pattern)
+update_board_status $ISSUE_NUMBER "$NEW_STAGE" # non-blocking
+```
+
+Transitions in run.md:
+- `planning` — GSD execution begins
+- `executing` — executor agent active
+- `verifying` — verifier agent active
+- `pr-created` — PR created
+- `done` — pipeline complete
+- `blocked` — blocking comment detected in preflight_comment_check
+
+## Consumers
+
+| Command | When Called |
+|---------|-------------|
+| issue.md | After writing pipeline_stage in write_state step |
+| run.md | After each pipeline_stage checkpoint write |
diff --git a/.claude/commands/mgw/workflows/state.md b/.claude/commands/mgw/workflows/state.md
index 112d3cd..9b0bc88 100644
--- a/.claude/commands/mgw/workflows/state.md
+++ b/.claude/commands/mgw/workflows/state.md
@@ -361,3 +361,4 @@ Only advance if ALL issues in current milestone completed successfully.
| Slug generation | issue.md, run.md |
| Project state | milestone.md, next.md, ask.md |
| Gate result schema | issue.md (populate), run.md (validate) |
+| Board status sync | board-sync.md (utility), issue.md (triage transitions), run.md (pipeline transitions) |