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) |