diff --git a/.claude/commands/mgw/board.md b/.claude/commands/mgw/board.md new file mode 100644 index 0000000..4469fd5 --- /dev/null +++ b/.claude/commands/mgw/board.md @@ -0,0 +1,972 @@ +--- +name: mgw:board +description: Create, show, and configure the GitHub Projects v2 board for this repo +argument-hint: "" +allowed-tools: + - Bash + - Read + - Write + - Edit +--- + + +Manage the GitHub Projects v2 board for the current MGW project. Three subcommands: + +- `create` — Idempotent: creates the board and custom fields if not yet in project.json. + If board already exists in project.json, exits cleanly with the board URL. +- `show` — Displays current board state: board URL, field IDs, and a summary of items + grouped by pipeline_stage. +- `configure` — Updates board field options (add new pipeline stages, GSD routes, etc.) + based on the current board-schema definitions. + +All board API calls use GitHub GraphQL v4. Board metadata is stored in project.json +under `project.project_board.fields`. Board item sync (adding issues as board items) +is handled by issue #73 — this command only creates the board structure. + +Command reads `.mgw/project.json` for context. Never hardcodes IDs. Follows delegation +boundary: board API calls in MGW, never application code reads. + + + +@~/.claude/commands/mgw/workflows/state.md +@~/.claude/commands/mgw/workflows/github.md + + + +Subcommand: $ARGUMENTS + +Repo detected via: gh repo view --json nameWithOwner -q .nameWithOwner +State: .mgw/project.json +Board schema: .mgw/board-schema.json (if exists) or embedded defaults from docs/BOARD-SCHEMA.md + + + + + +**Parse $ARGUMENTS and validate environment:** + +```bash +SUBCOMMAND=$(echo "$ARGUMENTS" | awk '{print $1}') + +if [ -z "$SUBCOMMAND" ]; then + echo "Usage: /mgw:board " + echo "" + echo " create Create board and custom fields (idempotent)" + echo " show Display board state and item counts" + echo " configure Update board field options" + exit 1 +fi + +case "$SUBCOMMAND" in + create|show|configure) ;; + *) + echo "Unknown subcommand: ${SUBCOMMAND}" + echo "Valid: create, show, configure" + exit 1 + ;; +esac +``` + +**Validate environment:** + +```bash +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -z "$REPO_ROOT" ]; then + echo "Not a git repository. Run from a repo root." + exit 1 +fi + +MGW_DIR="${REPO_ROOT}/.mgw" +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) +if [ -z "$REPO" ]; then + echo "No GitHub remote found. MGW requires a GitHub repo." + exit 1 +fi + +if [ ! -f "${MGW_DIR}/project.json" ]; then + echo "No project initialized. Run /mgw:project first." + exit 1 +fi + +OWNER=$(echo "$REPO" | cut -d'/' -f1) +REPO_NAME=$(echo "$REPO" | cut -d'/' -f2) +``` + + + +**Load project.json and extract board state:** + +```bash +PROJECT_JSON=$(cat "${MGW_DIR}/project.json") + +PROJECT_NAME=$(echo "$PROJECT_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['project']['name'])") + +# Check for existing board in project.json +BOARD_NUMBER=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(board.get('number', '')) +" 2>/dev/null) + +BOARD_URL=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(board.get('url', '')) +" 2>/dev/null) + +BOARD_NODE_ID=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(board.get('node_id', '')) +" 2>/dev/null) + +FIELDS_JSON=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +print(json.dumps(board.get('fields', {}))) +" 2>/dev/null || echo "{}") + +# Board exists if it has a node_id stored +BOARD_CONFIGURED=$([ -n "$BOARD_NODE_ID" ] && echo "true" || echo "false") +``` + + + +**Execute 'create' subcommand:** + +Only run if `$SUBCOMMAND = "create"`. + +**Idempotency check:** + +```bash +if [ "$SUBCOMMAND" = "create" ]; then + if [ "$BOARD_CONFIGURED" = "true" ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD ALREADY CONFIGURED" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "Node ID: ${BOARD_NODE_ID}" + echo "" + echo "Custom fields:" + echo "$FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +for name, data in fields.items(): + print(f\" {name}: {data.get('field_id', 'unknown')} ({data.get('type','?')})\") +" 2>/dev/null + echo "" + echo "To update field options: /mgw:board configure" + echo "To see board items: /mgw:board show" + exit 0 + fi +``` + +**Get owner and repo node IDs (required for GraphQL mutations):** + +```bash + OWNER_ID=$(gh api graphql -f query=' + query($login: String!) { + user(login: $login) { id } + } + ' -f login="$OWNER" --jq '.data.user.id' 2>/dev/null) + + # Fall back to org if user lookup fails + if [ -z "$OWNER_ID" ]; then + OWNER_ID=$(gh api graphql -f query=' + query($login: String!) { + organization(login: $login) { id } + } + ' -f login="$OWNER" --jq '.data.organization.id' 2>/dev/null) + fi + + if [ -z "$OWNER_ID" ]; then + echo "ERROR: Cannot resolve owner ID for '${OWNER}'. Check your GitHub token permissions." + exit 1 + fi + + REPO_NODE_ID=$(gh api graphql -f query=' + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { id } + } + ' -f owner="$OWNER" -f name="$REPO_NAME" --jq '.data.repository.id' 2>/dev/null) +``` + +**Create the project board:** + +```bash + BOARD_TITLE="${PROJECT_NAME} — MGW Pipeline Board" + echo "Creating GitHub Projects v2 board: '${BOARD_TITLE}'..." + + CREATE_RESULT=$(gh api graphql -f query=' + mutation($ownerId: ID!, $title: String!) { + createProjectV2(input: { + ownerId: $ownerId + title: $title + }) { + projectV2 { + id + number + url + } + } + } + ' -f ownerId="$OWNER_ID" -f title="$BOARD_TITLE" 2>&1) + + NEW_PROJECT_ID=$(echo "$CREATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2']['projectV2']['id']) +" 2>/dev/null) + + NEW_PROJECT_NUMBER=$(echo "$CREATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2']['projectV2']['number']) +" 2>/dev/null) + + NEW_PROJECT_URL=$(echo "$CREATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2']['projectV2']['url']) +" 2>/dev/null) + + if [ -z "$NEW_PROJECT_ID" ]; then + echo "ERROR: Failed to create project board." + echo "GraphQL response: ${CREATE_RESULT}" + exit 1 + fi + + echo " Created board: #${NEW_PROJECT_NUMBER} — ${NEW_PROJECT_URL}" + echo " Board node ID: ${NEW_PROJECT_ID}" +``` + +**Create custom fields (Status, AI Agent State, Milestone, Phase, GSD Route):** + +Field definitions follow docs/BOARD-SCHEMA.md from issue #71. + +```bash + echo "" + echo "Creating custom fields..." + + # Field 1: Status (SINGLE_SELECT — maps to pipeline_stage) + STATUS_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: SINGLE_SELECT + name: "Status" + singleSelectOptions: [ + { name: "New", color: GRAY, description: "Issue created, not yet triaged" } + { name: "Triaged", color: BLUE, description: "Triage complete, ready for execution" } + { name: "Needs Info", color: YELLOW, description: "Blocked at triage gate" } + { name: "Needs Security Review", color: RED, description: "High security risk flagged" } + { name: "Discussing", color: PURPLE, description: "Awaiting stakeholder scope approval" } + { name: "Approved", color: GREEN, description: "Cleared for execution" } + { name: "Planning", color: BLUE, description: "GSD planner agent active" } + { name: "Executing", color: ORANGE, description: "GSD executor agent active" } + { name: "Verifying", color: BLUE, description: "GSD verifier agent active" } + { name: "PR Created", color: GREEN, description: "PR open, awaiting review" } + { name: "Done", color: GREEN, description: "PR merged, issue closed" } + { name: "Failed", color: RED, description: "Unrecoverable pipeline error" } + { name: "Blocked", color: RED, description: "Blocking comment detected" } + ] + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + STATUS_FIELD_ID=$(echo "$STATUS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + # Build option ID map from result + STATUS_OPTIONS=$(echo "$STATUS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +options = d['data']['createProjectV2Field']['projectV2Field']['options'] +# Map lowercase pipeline_stage keys to option IDs +stage_map = { + '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' +} +name_to_id = {o['name']: o['id'] for o in options} +result = {stage: name_to_id.get(display, '') for stage, display in stage_map.items()} +print(json.dumps(result)) +" 2>/dev/null || echo "{}") + + if [ -n "$STATUS_FIELD_ID" ]; then + echo " Status field created: ${STATUS_FIELD_ID}" + else + echo " WARNING: Status field creation failed: ${STATUS_RESULT}" + fi + + # Field 2: AI Agent State (TEXT) + AI_STATE_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: TEXT + name: "AI Agent State" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + AI_STATE_FIELD_ID=$(echo "$AI_STATE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + if [ -n "$AI_STATE_FIELD_ID" ]; then + echo " AI Agent State field created: ${AI_STATE_FIELD_ID}" + else + echo " WARNING: AI Agent State field creation failed" + fi + + # Field 3: Milestone (TEXT) + MILESTONE_FIELD_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: TEXT + name: "Milestone" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + MILESTONE_FIELD_ID=$(echo "$MILESTONE_FIELD_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + if [ -n "$MILESTONE_FIELD_ID" ]; then + echo " Milestone field created: ${MILESTONE_FIELD_ID}" + else + echo " WARNING: Milestone field creation failed" + fi + + # Field 4: Phase (TEXT) + PHASE_FIELD_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: TEXT + name: "Phase" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + PHASE_FIELD_ID=$(echo "$PHASE_FIELD_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + if [ -n "$PHASE_FIELD_ID" ]; then + echo " Phase field created: ${PHASE_FIELD_ID}" + else + echo " WARNING: Phase field creation failed" + fi + + # Field 5: GSD Route (SINGLE_SELECT) + GSD_ROUTE_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!) { + createProjectV2Field(input: { + projectId: $projectId + dataType: SINGLE_SELECT + name: "GSD Route" + singleSelectOptions: [ + { name: "quick", color: BLUE, description: "Small/atomic task, direct execution" } + { name: "quick --full", color: BLUE, description: "Small task with plan-checker and verifier" } + { name: "plan-phase", color: PURPLE, description: "Medium task with phase planning" } + { name: "new-milestone", color: ORANGE, description: "Large task with full milestone lifecycle" } + ] + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + ' -f projectId="$NEW_PROJECT_ID" 2>&1) + + GSD_ROUTE_FIELD_ID=$(echo "$GSD_ROUTE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2Field']['projectV2Field']['id']) +" 2>/dev/null) + + GSD_ROUTE_OPTIONS=$(echo "$GSD_ROUTE_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +options = d['data']['createProjectV2Field']['projectV2Field']['options'] +route_map = { + 'gsd:quick': 'quick', 'gsd:quick --full': 'quick --full', + 'gsd:plan-phase': 'plan-phase', 'gsd:new-milestone': 'new-milestone' +} +name_to_id = {o['name']: o['id'] for o in options} +result = {route: name_to_id.get(display, '') for route, display in route_map.items()} +print(json.dumps(result)) +" 2>/dev/null || echo "{}") + + if [ -n "$GSD_ROUTE_FIELD_ID" ]; then + echo " GSD Route field created: ${GSD_ROUTE_FIELD_ID}" + else + echo " WARNING: GSD Route field creation failed" + fi +``` + +**Update project.json with board metadata:** + +```bash + echo "" + echo "Updating project.json with board metadata..." + + python3 << PYEOF +import json + +with open('${MGW_DIR}/project.json') as f: + project = json.load(f) + +# Build field schema +status_options = json.loads('''${STATUS_OPTIONS}''') if '${STATUS_OPTIONS}' != '{}' else {} +gsd_route_options = json.loads('''${GSD_ROUTE_OPTIONS}''') if '${GSD_ROUTE_OPTIONS}' != '{}' else {} + +fields = {} + +if '${STATUS_FIELD_ID}': + fields['status'] = { + 'field_id': '${STATUS_FIELD_ID}', + 'field_name': 'Status', + 'type': 'SINGLE_SELECT', + 'options': status_options + } + +if '${AI_STATE_FIELD_ID}': + fields['ai_agent_state'] = { + 'field_id': '${AI_STATE_FIELD_ID}', + 'field_name': 'AI Agent State', + 'type': 'TEXT' + } + +if '${MILESTONE_FIELD_ID}': + fields['milestone'] = { + 'field_id': '${MILESTONE_FIELD_ID}', + 'field_name': 'Milestone', + 'type': 'TEXT' + } + +if '${PHASE_FIELD_ID}': + fields['phase'] = { + 'field_id': '${PHASE_FIELD_ID}', + 'field_name': 'Phase', + 'type': 'TEXT' + } + +if '${GSD_ROUTE_FIELD_ID}': + fields['gsd_route'] = { + 'field_id': '${GSD_ROUTE_FIELD_ID}', + 'field_name': 'GSD Route', + 'type': 'SINGLE_SELECT', + 'options': gsd_route_options + } + +# Update project_board section +project['project']['project_board'] = { + 'number': int('${NEW_PROJECT_NUMBER}') if '${NEW_PROJECT_NUMBER}'.isdigit() else None, + 'url': '${NEW_PROJECT_URL}', + 'node_id': '${NEW_PROJECT_ID}', + 'fields': fields +} + +with open('${MGW_DIR}/project.json', 'w') as f: + json.dump(project, f, indent=2) + +print('project.json updated') +PYEOF + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD CREATED" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${NEW_PROJECT_NUMBER} — ${NEW_PROJECT_URL}" + echo "Node ID: ${NEW_PROJECT_ID}" + echo "" + echo "Custom fields created:" + echo " status ${STATUS_FIELD_ID:-FAILED} (SINGLE_SELECT, 13 options)" + echo " ai_agent_state ${AI_STATE_FIELD_ID:-FAILED} (TEXT)" + echo " milestone ${MILESTONE_FIELD_ID:-FAILED} (TEXT)" + echo " phase ${PHASE_FIELD_ID:-FAILED} (TEXT)" + echo " gsd_route ${GSD_ROUTE_FIELD_ID:-FAILED} (SINGLE_SELECT, 4 options)" + echo "" + echo "Field IDs stored in .mgw/project.json" + echo "" + echo "Next:" + echo " /mgw:board show Display board state" + echo " /mgw:run 73 Sync issues onto board items (#73)" + +fi # end create subcommand +``` + + + +**Execute 'show' subcommand:** + +Only run if `$SUBCOMMAND = "show"`. + +```bash +if [ "$SUBCOMMAND" = "show" ]; then + if [ "$BOARD_CONFIGURED" = "false" ]; then + echo "No board configured. Run /mgw:board create first." + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD STATE: ${PROJECT_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "Node ID: ${BOARD_NODE_ID}" + echo "" + echo "Custom Fields:" + echo "$FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +for name, data in fields.items(): + fid = data.get('field_id', 'unknown') + ftype = data.get('type', 'unknown') + fname = data.get('field_name', name) + if ftype == 'SINGLE_SELECT': + opts = len(data.get('options', {})) + print(f' {fname:<20} {fid} ({ftype}, {opts} options)') + else: + print(f' {fname:<20} {fid} ({ftype})') +" 2>/dev/null + echo "" +``` + +**Fetch board items from GitHub to show current state:** + +```bash + echo "Fetching board items from GitHub..." + + ITEMS_RESULT=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { + title + items(first: 50) { + totalCount + nodes { + id + content { + ... on Issue { + number + title + state + } + ... on PullRequest { + number + title + state + } + } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { name } } + } + } + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + + # Fall back to org query if user query fails + if echo "$ITEMS_RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); _ = d['data']['user']['projectV2']" 2>/dev/null; then + ITEM_NODES=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(json.dumps(d['data']['user']['projectV2']['items']['nodes'])) +" 2>/dev/null || echo "[]") + TOTAL_ITEMS=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['user']['projectV2']['items']['totalCount']) +" 2>/dev/null || echo "0") + else + # Try organization lookup + ITEMS_RESULT=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + title + items(first: 50) { + totalCount + nodes { + id + content { + ... on Issue { number title state } + ... on PullRequest { number title state } + } + fieldValues(first: 10) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { name } } + } + } + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + + ITEM_NODES=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +org_data = d.get('data', {}).get('organization') or d.get('data', {}).get('user', {}) +proj = org_data.get('projectV2', {}) +print(json.dumps(proj.get('items', {}).get('nodes', []))) +" 2>/dev/null || echo "[]") + TOTAL_ITEMS=$(echo "$ITEMS_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +org_data = d.get('data', {}).get('organization') or d.get('data', {}).get('user', {}) +proj = org_data.get('projectV2', {}) +print(proj.get('items', {}).get('totalCount', 0)) +" 2>/dev/null || echo "0") + fi + + echo "Board Items (${TOTAL_ITEMS} total):" + echo "" + + echo "$ITEM_NODES" | python3 -c " +import json,sys +nodes = json.load(sys.stdin) + +if not nodes: + print(' No items on board yet.') + print(' Run /mgw:run 73 to sync issues as board items (#73).') + sys.exit(0) + +# Group by Status field +by_status = {} +for node in nodes: + content = node.get('content', {}) + num = content.get('number', '?') + title = content.get('title', 'Unknown')[:45] + status = 'No Status' + for fv in node.get('fieldValues', {}).get('nodes', []): + field = fv.get('field', {}) + if field.get('name') == 'Status': + status = fv.get('name', 'No Status') + break + by_status.setdefault(status, []).append((num, title)) + +order = ['Executing', 'Planning', 'Verifying', 'PR Created', 'Triaged', 'Approved', + 'Discussing', 'New', 'Needs Info', 'Needs Security Review', 'Blocked', 'Failed', 'Done', 'No Status'] + +for status in order: + items = by_status.pop(status, []) + if items: + print(f' {status} ({len(items)}):') + for num, title in items: + print(f' #{num} {title}') + +for status, items in by_status.items(): + print(f' {status} ({len(items)}):') + for num, title in items: + print(f' #{num} {title}') +" 2>/dev/null + + echo "" + echo "Open board: ${BOARD_URL}" + +fi # end show subcommand +``` + + + +**Execute 'configure' subcommand:** + +Only run if `$SUBCOMMAND = "configure"`. + +Reads current field options from GitHub and compares to the canonical schema in +docs/BOARD-SCHEMA.md / .mgw/board-schema.json. Adds any missing options. + +```bash +if [ "$SUBCOMMAND" = "configure" ]; then + if [ "$BOARD_CONFIGURED" = "false" ]; then + echo "No board configured. Run /mgw:board create first." + exit 1 + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD CONFIGURE: ${PROJECT_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "" +``` + +**Fetch current field state from GitHub:** + +```bash + FIELDS_STATE=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + user(login: $owner) { + projectV2(number: $number) { + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name color description } + } + ... on ProjectV2Field { + id + name + dataType + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + + # Try org if user fails + if ! echo "$FIELDS_STATE" | python3 -c "import json,sys; d=json.load(sys.stdin); _ = d['data']['user']['projectV2']" 2>/dev/null; then + FIELDS_STATE=$(gh api graphql -f query=' + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + fields(first: 20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name color description } + } + ... on ProjectV2Field { + id + name + dataType + } + } + } + } + } + } + ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null) + fi + + echo "Current fields on board:" + echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +for node in nodes: + name = node.get('name', 'unknown') + nid = node.get('id', 'unknown') + opts = node.get('options') + if opts is not None: + print(f' {name} (SINGLE_SELECT, {len(opts)} options): {nid}') + for opt in opts: + print(f' - {opt[\"name\"]} ({opt[\"color\"]}) [{opt[\"id\"]}]') + else: + dtype = node.get('dataType', 'TEXT') + print(f' {name} ({dtype}): {nid}') +" 2>/dev/null || echo " (could not fetch field details)" + + echo "" +``` + +**Compare with canonical schema and identify missing options:** + +```bash + # Canonical Status options from BOARD-SCHEMA.md + CANONICAL_STATUS_OPTIONS='["New","Triaged","Needs Info","Needs Security Review","Discussing","Approved","Planning","Executing","Verifying","PR Created","Done","Failed","Blocked"]' + + # Get current Status option names + CURRENT_STATUS_OPTIONS=$(echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +for node in nodes: + if node.get('name') == 'Status' and 'options' in node: + print(json.dumps([o['name'] for o in node['options']])) + sys.exit(0) +print('[]') +" 2>/dev/null || echo "[]") + + MISSING_STATUS=$(python3 -c " +import json +canonical = json.loads('${CANONICAL_STATUS_OPTIONS}') +current = json.loads('''${CURRENT_STATUS_OPTIONS}''') +missing = [o for o in canonical if o not in current] +if missing: + print('Missing Status options: ' + ', '.join(missing)) +else: + print('Status field: all options present') +" 2>/dev/null) + + echo "Schema comparison:" + echo " ${MISSING_STATUS}" + + # Canonical GSD Route options + CANONICAL_GSD_OPTIONS='["quick","quick --full","plan-phase","new-milestone"]' + + CURRENT_GSD_OPTIONS=$(echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +for node in nodes: + if node.get('name') == 'GSD Route' and 'options' in node: + print(json.dumps([o['name'] for o in node['options']])) + sys.exit(0) +print('[]') +" 2>/dev/null || echo "[]") + + MISSING_GSD=$(python3 -c " +import json +canonical = json.loads('${CANONICAL_GSD_OPTIONS}') +current = json.loads('''${CURRENT_GSD_OPTIONS}''') +missing = [o for o in canonical if o not in current] +if missing: + print('Missing GSD Route options: ' + ', '.join(missing)) +else: + print('GSD Route field: all options present') +" 2>/dev/null) + + echo " ${MISSING_GSD}" + echo "" + + # Check for missing text fields + CURRENT_FIELD_NAMES=$(echo "$FIELDS_STATE" | python3 -c " +import json,sys +d = json.load(sys.stdin) +data = d.get('data', {}) +proj = (data.get('user') or data.get('organization', {})).get('projectV2', {}) +nodes = proj.get('fields', {}).get('nodes', []) +print(json.dumps([n.get('name') for n in nodes])) +" 2>/dev/null || echo "[]") + + REQUIRED_TEXT_FIELDS='["AI Agent State","Milestone","Phase"]' + MISSING_TEXT=$(python3 -c " +import json +required = json.loads('${REQUIRED_TEXT_FIELDS}') +current = json.loads('''${CURRENT_FIELD_NAMES}''') +missing = [f for f in required if f not in current] +if missing: + print('Missing text fields: ' + ', '.join(missing)) +else: + print('Text fields: all present') +" 2>/dev/null) + + echo " ${MISSING_TEXT}" + echo "" + + # Report: no automated field addition (GitHub Projects v2 API does not support + # updating existing single-select field options — must delete and recreate) + echo "Note: GitHub Projects v2 GraphQL does not support adding options to an" + echo "existing single-select field. To add new pipeline stages:" + echo " 1. Delete the existing Status field on the board UI" + echo " 2. Run /mgw:board create (idempotency check will be skipped for fields)" + echo " Or: manually add options via GitHub Projects UI at ${BOARD_URL}" + echo "" + echo "For missing text fields, run /mgw:board create (it will create missing fields)." + +fi # end configure subcommand +``` + + + + + +- [ ] parse_and_validate: subcommand parsed, git repo and GitHub remote confirmed, project.json exists +- [ ] load_project: project.json loaded, board state extracted (number, url, node_id, fields) +- [ ] create: idempotency check — exits cleanly if board already configured (board_node_id present) +- [ ] create: owner node ID resolved via GraphQL (user or org fallback) +- [ ] create: createProjectV2 mutation succeeds — board number, URL, node_id captured +- [ ] create: all 5 custom fields created (Status, AI Agent State, Milestone, Phase, GSD Route) +- [ ] create: Status field has 13 single-select options matching pipeline_stage values +- [ ] create: GSD Route field has 4 single-select options +- [ ] create: field IDs and option IDs stored in project.json under project.project_board.fields +- [ ] create: success report shows board URL, node ID, and field IDs +- [ ] show: board not configured → clear error message +- [ ] show: board URL and node ID displayed +- [ ] show: custom fields listed with IDs and types +- [ ] show: board items fetched from GitHub and grouped by Status field value +- [ ] show: handles empty board (no items) with helpful next-step message +- [ ] show: user/org GraphQL fallback handles both account types +- [ ] configure: board not configured → clear error message +- [ ] configure: fetches current field state from GitHub +- [ ] configure: compares against canonical schema, reports missing options +- [ ] configure: lists all missing Status options, GSD Route options, and text fields +- [ ] configure: explains GitHub Projects v2 limitation on adding options to existing fields + 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/pr.md b/.claude/commands/mgw/pr.md index 92d6195..1482693 100644 --- a/.claude/commands/mgw/pr.md +++ b/.claude/commands/mgw/pr.md @@ -29,6 +29,7 @@ Works in two modes: @~/.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 @@ -247,6 +248,11 @@ Update state file: - Set pipeline_stage to "pr-created" Add cross-ref: issue → PR link in cross-refs.json. + +Sync PR to board (non-blocking): +```bash +sync_pr_to_board $ISSUE_NUMBER $PR_NUMBER # non-blocking — add PR as board item +``` @@ -274,4 +280,5 @@ Testing procedures posted as PR comment. - [ ] Testing procedures posted as separate PR comment - [ ] State file updated with PR number (linked mode) - [ ] Cross-ref added (linked mode) +- [ ] PR added to board as board item after creation (non-blocking, linked mode only) diff --git a/.claude/commands/mgw/run.md b/.claude/commands/mgw/run.md index 7a6a780..7dcde4f 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,110 @@ REPO_ROOT=$(git rev-parse --show-toplevel) DEFAULT=$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name) ``` +Define the board sync utilities (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 +} + +update_board_agent_state() { + local ISSUE_NUMBER="$1" + local STATE_TEXT="$2" + if [ -z "$ISSUE_NUMBER" ]; 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('ai_agent_state',{}).get('field_id','')) + else: + p=json.load(open('${REPO_ROOT}/.mgw/project.json')) + print(p.get('project',{}).get('project_board',{}).get('fields',{}).get('ai_agent_state',{}).get('field_id','')) +except: print('') +" 2>/dev/null || echo "") + if [ -z "$FIELD_ID" ]; then return 0; fi + gh api graphql -f query=' + mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$text:String!){ + updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:{text:$text}}){projectV2Item{id}} + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$FIELD_ID" -f text="$STATE_TEXT" 2>/dev/null || true +} +``` + Parse $ARGUMENTS for issue number. If missing: ``` AskUserQuestion( @@ -248,7 +353,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 +444,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 = "" @@ -373,6 +481,9 @@ mkdir -p "$QUICK_DIR" ``` 3. **Spawn planner (task agent):** +```bash +update_board_agent_state $ISSUE_NUMBER "Planning" # non-blocking agent state +``` ``` Task( prompt=" @@ -475,6 +586,9 @@ If issues found and iteration < 2: spawn planner revision, then re-check. If iteration >= 2: offer force proceed or abort. 7. **Spawn executor (task agent):** +```bash +update_board_agent_state $ISSUE_NUMBER "Executing" # non-blocking agent state +``` ``` Task( prompt=" @@ -506,6 +620,9 @@ VERIFY_RESULT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs verify-summary "$ Parse JSON result. Use `passed` field for go/no-go. Checks summary existence, files created, and commits. 9. **(If --full) Spawn verifier:** +```bash +update_board_agent_state $ISSUE_NUMBER "Verifying" # non-blocking agent state +``` ``` Task( prompt=" @@ -539,6 +656,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 +696,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 +744,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: @@ -661,6 +785,9 @@ If proceed: apply "mgw:approved" label and continue. ``` **b. Spawn planner agent (gsd:plan-phase):** + ```bash + update_board_agent_state $ISSUE_NUMBER "Planning phase ${PHASE_NUMBER}" # non-blocking agent state + ``` ``` Task( prompt=" @@ -707,6 +834,7 @@ If proceed: apply "mgw:approved" label and continue. ```bash EXEC_INIT=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs init execute-phase "${PHASE_NUMBER}") # Parse EXEC_INIT JSON for: executor_model, verifier_model, phase_dir, plans, incomplete_plans, plan_count + update_board_agent_state $ISSUE_NUMBER "Executing phase ${PHASE_NUMBER}" # non-blocking agent state ``` ``` Task( @@ -739,6 +867,9 @@ If proceed: apply "mgw:approved" label and continue. ``` **e. Spawn verifier agent (gsd:verify-phase):** + ```bash + update_board_agent_state $ISSUE_NUMBER "Verifying phase ${PHASE_NUMBER}" # non-blocking agent state + ``` ``` Task( prompt=" @@ -785,6 +916,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 +956,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 +1129,12 @@ 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 +update_board_agent_state $ISSUE_NUMBER "" # clear agent state after PR creation (non-blocking) +sync_pr_to_board $ISSUE_NUMBER $PR_NUMBER # non-blocking — add PR as board item +``` + Add cross-ref (at `${REPO_ROOT}/.mgw/cross-refs.json`): issue → PR. @@ -1052,6 +1195,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 +1238,10 @@ 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) +- [ ] AI Agent State field set before each GSD agent spawn (non-blocking) +- [ ] AI Agent State field cleared after PR creation (non-blocking) +- [ ] PR added to board as board item after creation (non-blocking) +- [ ] Board sync failures never block pipeline execution - [ ] User prompted to run /mgw:sync after merge diff --git a/.claude/commands/mgw/sync.md b/.claude/commands/mgw/sync.md index 5919117..df86c66 100644 --- a/.claude/commands/mgw/sync.md +++ b/.claude/commands/mgw/sync.md @@ -24,6 +24,7 @@ Run periodically or when starting a new session to get a clean view. @~/.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 @@ -88,6 +89,53 @@ fi This is read-only and additive — health status is included in the sync summary but does not block any reconciliation actions. + +**Board reconciliation — ensure PR cross-refs are reflected on the board (non-blocking):** + +If the project board is configured, check cross-refs for any issue→PR `implements` links +and ensure each linked PR exists as a board item. Uses `sync_pr_to_board` from +board-sync.md which is idempotent — adding a PR that's already on the board is a no-op. + +```bash +# Non-blocking throughout — board sync failures never block reconciliation +if [ -f "${REPO_ROOT}/.mgw/project.json" ] && [ -f "${REPO_ROOT}/.mgw/cross-refs.json" ]; then + 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 [ -n "$BOARD_NODE_ID" ]; then + # Find all issue→PR implements links in cross-refs + PR_LINKS=$(python3 -c " +import json +refs = json.load(open('${REPO_ROOT}/.mgw/cross-refs.json')) +for link in refs.get('links', []): + if link.get('type') == 'implements' and link['a'].startswith('issue:') and link['b'].startswith('pr:'): + issue_num = link['a'].split(':')[1] + pr_num = link['b'].split(':')[1] + print(f'{issue_num} {pr_num}') +" 2>/dev/null || echo "") + + # For each issue→PR link, ensure the PR is on the board + PR_SYNCED=0 + while IFS=' ' read -r LINKED_ISSUE LINKED_PR; do + [ -z "$LINKED_PR" ] && continue + sync_pr_to_board "$LINKED_ISSUE" "$LINKED_PR" # non-blocking + PR_SYNCED=$((PR_SYNCED + 1)) + done <<< "$PR_LINKS" + + if [ "$PR_SYNCED" -gt 0 ]; then + echo "MGW: Board reconciliation — checked ${PR_SYNCED} PR cross-ref(s)" + fi + fi +fi +``` + + **Take action per classification:** @@ -168,5 +216,6 @@ ${comment_drift_details ? 'Unreviewed comments:\n' + comment_drift_details : ''} - [ ] Lingering worktrees cleaned up for completed items - [ ] Branch deletion offered for completed items - [ ] Stale/orphaned/drift items flagged (including comment drift) +- [ ] Board reconciliation run — all PR cross-refs checked against board (non-blocking) - [ ] Summary presented diff --git a/.claude/commands/mgw/workflows/board-sync.md b/.claude/commands/mgw/workflows/board-sync.md new file mode 100644 index 0000000..a35e692 --- /dev/null +++ b/.claude/commands/mgw/workflows/board-sync.md @@ -0,0 +1,403 @@ + +Shared board sync utilities for MGW pipeline commands. Three functions are exported: + +- update_board_status — Called after any pipeline_stage transition to update the board + item's Status (single-select) field. +- update_board_agent_state — Called around GSD agent spawns to surface the active agent + in the board item's "AI Agent State" (text) field. Cleared after PR creation. +- sync_pr_to_board — Called after PR creation to add the PR as a board item (PR-type + item linked to the issue's board item). + +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 +} +``` + +## update_board_agent_state + +Call this function before spawning each GSD agent and after PR creation to surface +real-time agent activity in the board item's "AI Agent State" text field. + +```bash +# update_board_agent_state — Update AI Agent State text field on the board item +# Args: ISSUE_NUMBER, STATE_TEXT (empty string to clear the field) +# Non-blocking: all failures are silent no-ops +update_board_agent_state() { + local ISSUE_NUMBER="$1" + local STATE_TEXT="$2" + + if [ -z "$ISSUE_NUMBER" ]; 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 + + # Get the AI Agent State field ID from board-schema.json or project.json + 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('ai_agent_state', {}).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('ai_agent_state', {}).get('field_id', '')) +except: + print('') +" 2>/dev/null || echo "") + if [ -z "$FIELD_ID" ]; then return 0; fi + + # Update the AI Agent State text field on the board item (non-blocking) + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $text } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" \ + -f itemId="$ITEM_ID" \ + -f fieldId="$FIELD_ID" \ + -f text="$STATE_TEXT" 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) + +## AI Agent State Values + +The AI Agent State text field is set before each GSD agent spawn and cleared after PR creation: + +| Trigger | Value | +|---------|-------| +| Before gsd-planner spawn (quick route) | `"Planning"` | +| Before gsd-executor spawn (quick route) | `"Executing"` | +| Before gsd-verifier spawn (quick route) | `"Verifying"` | +| Before gsd-planner spawn (milestone, phase N) | `"Planning phase N"` | +| Before gsd-executor spawn (milestone, phase N) | `"Executing phase N"` | +| Before gsd-verifier spawn (milestone, phase N) | `"Verifying phase N"` | +| After PR created | `""` (clears the field) | + +## 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` (status) | `board-schema.json` or `project.json` → `fields.status.field_id` | +| `OPTION_ID` | `board-schema.json` or `project.json` → `fields.status.options.` | +| `FIELD_ID` (agent state) | `board-schema.json` or `project.json` → `fields.ai_agent_state.field_id` | + +## 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 +- AI Agent State 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 both utilities in any MGW command that spawns GSD agents. + +### update_board_status — 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 + +### update_board_status — 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 + +### update_board_agent_state — in run.md (around agent spawns) + +Called immediately before spawning each GSD agent and after PR creation: +```bash +# Before spawning gsd-planner +update_board_agent_state $ISSUE_NUMBER "Planning phase ${PHASE_NUM}" + +# Before spawning gsd-executor +update_board_agent_state $ISSUE_NUMBER "Executing phase ${PHASE_NUM}" + +# Before spawning gsd-verifier +update_board_agent_state $ISSUE_NUMBER "Verifying phase ${PHASE_NUM}" + +# After PR created (clear the field) +update_board_agent_state $ISSUE_NUMBER "" +``` + +### sync_pr_to_board — in run.md and pr.md (after PR creation) + +Called immediately after `gh pr create` succeeds in both run.md and pr.md (linked mode): +```bash +# After PR created +sync_pr_to_board $ISSUE_NUMBER $PR_NUMBER # non-blocking board PR link +``` + +## sync_pr_to_board + +Call this function after PR creation to add the PR as a board item. In GitHub Projects v2, +`addProjectV2ItemById` with a PR's node ID creates a PR-type item that GitHub Projects +tracks separately from the issue item. + +```bash +# sync_pr_to_board — Add PR as a board item, linked to the issue's board item +# Args: ISSUE_NUMBER, PR_NUMBER +# Non-blocking: all failures are silent no-ops +sync_pr_to_board() { + local ISSUE_NUMBER="$1" + local PR_NUMBER="$2" + + if [ -z "$ISSUE_NUMBER" ] || [ -z "$PR_NUMBER" ]; 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 PR node ID from GitHub (non-blocking) + PR_NODE_ID=$(gh pr view "$PR_NUMBER" --json id -q .id 2>/dev/null || echo "") + if [ -z "$PR_NODE_ID" ]; then return 0; fi + + # Add PR to board as a PR-type item (creates a separate board entry linked to the PR) + ITEM_ID=$(gh api graphql -f query=' + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + } + ' -f projectId="$BOARD_NODE_ID" -f contentId="$PR_NODE_ID" \ + --jq '.data.addProjectV2ItemById.item.id' 2>/dev/null || echo "") + + if [ -n "$ITEM_ID" ]; then + echo "MGW: PR #${PR_NUMBER} added to board (item: ${ITEM_ID})" + fi +} +``` + +## sync_pr_to_board — Board Reconciliation in sync.md + +During `mgw:sync`, after cross-refs are loaded, check for any `implements` links +(issue → PR) that don't yet have a board item for the PR. For each such link, call +`sync_pr_to_board` to ensure the board reflects all linked PRs. + +```bash +# Board reconciliation — ensure all PR cross-refs have board items (non-blocking) +if [ -f "${REPO_ROOT}/.mgw/project.json" ] && [ -f "${REPO_ROOT}/.mgw/cross-refs.json" ]; then + 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 [ -n "$BOARD_NODE_ID" ]; then + # Find all issue→PR implements links in cross-refs + PR_LINKS=$(python3 -c " +import json +refs = json.load(open('${REPO_ROOT}/.mgw/cross-refs.json')) +for link in refs.get('links', []): + if link.get('type') == 'implements' and link['a'].startswith('issue:') and link['b'].startswith('pr:'): + issue_num = link['a'].split(':')[1] + pr_num = link['b'].split(':')[1] + print(f'{issue_num} {pr_num}') +" 2>/dev/null || echo "") + + # For each issue→PR link, ensure the PR is on the board (sync_pr_to_board is idempotent) + while IFS=' ' read -r LINKED_ISSUE LINKED_PR; do + [ -z "$LINKED_PR" ] && continue + sync_pr_to_board "$LINKED_ISSUE" "$LINKED_PR" # non-blocking + done <<< "$PR_LINKS" + fi +fi +``` + +## Consumers + +| Command | Function | When Called | +|---------|----------|-------------| +| issue.md | update_board_status | After writing pipeline_stage in write_state step | +| run.md | update_board_status | After each pipeline_stage checkpoint write | +| run.md | update_board_agent_state | Before each GSD agent spawn, and after PR creation | +| run.md | sync_pr_to_board | After PR creation (before cross-ref is recorded) | +| pr.md | sync_pr_to_board | After PR creation in create_pr step (linked mode only) | +| sync.md | sync_pr_to_board | Board reconciliation — for each PR link in cross-refs | 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) |