From 85e1a816ad7893d91db61e2b549eb27d1569ad27 Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:00:16 -0600 Subject: [PATCH 01/10] docs(board): design GitHub Projects v2 board schema with MGW custom fields Add docs/BOARD-SCHEMA.md documenting the full GitHub Projects v2 board field schema for MGW pipeline visibility. Defines 5 custom fields: - Status (single-select, 13 options mapping 1:1 to pipeline_stage values) - AI Agent State (text, shows active GSD agent) - Milestone (text, from project.json) - Phase (text, number + name format) - GSD Route (single-select, 4 options for all known routes) Includes GraphQL mutation templates, project.json board key schema, and board view planning for downstream issues (#72-#79). Closes #71 Co-Authored-By: Claude Sonnet 4.6 --- docs/BOARD-SCHEMA.md | 394 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 docs/BOARD-SCHEMA.md diff --git a/docs/BOARD-SCHEMA.md b/docs/BOARD-SCHEMA.md new file mode 100644 index 0000000..1f72223 --- /dev/null +++ b/docs/BOARD-SCHEMA.md @@ -0,0 +1,394 @@ +# MGW GitHub Projects v2 Board Schema + +This document defines the GitHub Projects v2 board schema used by MGW to surface pipeline +state, AI agent activity, and milestone context to teams. + +## Board Overview + +MGW manages a GitHub Projects v2 board for each project. The board is created and +configured by the `mgw:board` command and stored in `.mgw/project.json` under the `board` +key. Board items are synced from project.json milestones and issues. + +**Purpose:** Give teams real-time visibility into the AI-driven pipeline — which issues +are being worked on, what GSD route is executing, which milestone phase is active, and +whether the pipeline is healthy. + +**Board URL format:** `https://github.com/users//projects/` + +## Custom Field Schema + +### 1. Status (Single Select) + +**Type:** `SINGLE_SELECT` +**Description:** Mirrors the `pipeline_stage` value in `.mgw/active/-.json`. Updated automatically by `mgw:run` on each stage transition. + +**Options (maps 1:1 to pipeline_stage values):** + +| Option | Color | Description | +|--------|-------|-------------| +| `New` | `GRAY` | Issue created, not yet triaged | +| `Triaged` | `BLUE` | Triage complete, ready for execution | +| `Needs Info` | `YELLOW` | Blocked at triage gate — insufficient detail | +| `Needs Security Review` | `RED` | Blocked — high security risk flagged | +| `Discussing` | `PURPLE` | Scope proposal posted, awaiting stakeholder input | +| `Approved` | `GREEN` | Discussion complete, cleared for execution | +| `Planning` | `BLUE` | GSD planner agent active | +| `Executing` | `ORANGE` | GSD executor agent active | +| `Verifying` | `BLUE` | GSD verifier agent active | +| `PR Created` | `GREEN` | Pull request open and ready for review | +| `Done` | `GREEN` | PR merged, issue closed | +| `Failed` | `RED` | Unrecoverable pipeline error | +| `Blocked` | `RED` | Blocking comment detected — pipeline paused | + +**pipeline_stage → Status mapping:** +``` +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 +``` + +**GraphQL mutation to create:** +```graphql +mutation { + createProjectV2Field(input: { + 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 } + } + } + } +} +``` + +--- + +### 2. AI Agent State (Text) + +**Type:** `TEXT` +**Description:** Shows the current GSD agent activity during pipeline execution. Updated at each agent spawn. Cleared when pipeline reaches `pr-created`. + +**Values (written programmatically):** +- `planner:running` — GSD planner agent spawned +- `executor:running` — GSD executor agent spawned +- `verifier:running` — GSD verifier agent active +- `plan-checker:running` — GSD plan-checker agent active +- `idle` — No agent active (between stages) +- `blocked` — Pipeline blocked (see Status field) + +**GraphQL mutation to create:** +```graphql +mutation { + createProjectV2Field(input: { + projectId: "" + dataType: TEXT + name: "AI Agent State" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } +} +``` + +--- + +### 3. Milestone (Text) + +**Type:** `TEXT` +**Description:** The milestone name from `project.json`. Set when an issue is added to a milestone via `mgw:project` or `mgw:milestone`. Not auto-updated — reflects the milestone at issue creation time. + +**Value format:** Milestone title string (e.g., `v2 — GitHub Projects Board Management`) + +**GraphQL mutation to create:** +```graphql +mutation { + createProjectV2Field(input: { + projectId: "" + dataType: TEXT + name: "Milestone" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } +} +``` + +--- + +### 4. Phase (Text) + +**Type:** `TEXT` +**Description:** The phase name and number from `project.json`. Format: ``. Set when the issue enters the pipeline. Not auto-updated. + +**Value format:** `13 — Board Foundation & Field Schema` + +**GraphQL mutation to create:** +```graphql +mutation { + createProjectV2Field(input: { + projectId: "" + dataType: TEXT + name: "Phase" + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + } + } +} +``` + +--- + +### 5. GSD Route (Single Select) + +**Type:** `SINGLE_SELECT` +**Description:** The GSD execution route assigned during triage. Determines how the pipeline executes the issue. Set during triage, does not change. + +**Options:** + +| Option | Color | Description | +|--------|-------|-------------| +| `quick` | `BLUE` | Small/atomic tasks — no plan file, direct execution | +| `quick --full` | `BLUE` | Small tasks with plan-checker and verifier | +| `plan-phase` | `PURPLE` | Medium tasks — structured phase planning | +| `new-milestone` | `ORANGE` | Large tasks — full milestone lifecycle | + +**GSD route name → option mapping:** +``` +gsd:quick → quick +gsd:quick --full → quick --full +gsd:plan-phase → plan-phase +gsd:new-milestone → new-milestone +``` + +**GraphQL mutation to create:** +```graphql +mutation { + createProjectV2Field(input: { + 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 } + } + } + } +} +``` + +--- + +## project.json Board Key Schema + +The `mgw:board` command (#72) writes board metadata to `.mgw/project.json` under the `project.board` key. This schema defines what that key should contain: + +```json +{ + "project": { + "name": "my-project", + "repo": "owner/repo", + "project_board": { + "number": 9, + "url": "https://github.com/users/owner/projects/9", + "node_id": "PVT_kwDOABC123", + "fields": { + "status": { + "field_id": "PVTSSF_...", + "field_name": "Status", + "type": "SINGLE_SELECT", + "options": { + "new": "option_id_1", + "triaged": "option_id_2", + "needs-info": "option_id_3", + "needs-security-review": "option_id_4", + "discussing": "option_id_5", + "approved": "option_id_6", + "planning": "option_id_7", + "executing": "option_id_8", + "verifying": "option_id_9", + "pr-created": "option_id_10", + "done": "option_id_11", + "failed": "option_id_12", + "blocked": "option_id_13" + } + }, + "ai_agent_state": { + "field_id": "PVTF_...", + "field_name": "AI Agent State", + "type": "TEXT" + }, + "milestone": { + "field_id": "PVTF_...", + "field_name": "Milestone", + "type": "TEXT" + }, + "phase": { + "field_id": "PVTF_...", + "field_name": "Phase", + "type": "TEXT" + }, + "gsd_route": { + "field_id": "PVTSSF_...", + "field_name": "GSD Route", + "type": "SINGLE_SELECT", + "options": { + "gsd:quick": "option_id_a", + "gsd:quick --full": "option_id_b", + "gsd:plan-phase": "option_id_c", + "gsd:new-milestone": "option_id_d" + } + } + } + } + } +} +``` + +## Board Creation Workflow (for mgw:board — #72) + +The `mgw:board` command will use this schema to: + +1. **Fetch or create the board** via GraphQL `createProjectV2` +2. **Create each custom field** using the mutations above +3. **Store field IDs** in `project.json` under `project.project_board.fields` +4. **Add existing milestone issues** as board items +5. **Set initial field values** from `project.json` milestone data + +### GraphQL: Create Project Board +```graphql +mutation { + createProjectV2(input: { + ownerId: "" + title: " — MGW Pipeline Board" + repositoryId: "" + }) { + projectV2 { + id + number + url + } + } +} +``` + +### GraphQL: Add Issue to Board +```graphql +mutation { + addProjectV2ItemById(input: { + projectId: "" + contentId: "" + }) { + item { + id + } + } +} +``` + +### GraphQL: Update Field Value (Single Select) +```graphql +mutation { + updateProjectV2ItemFieldValue(input: { + projectId: "" + itemId: "" + fieldId: "" + value: { + singleSelectOptionId: "" + } + }) { + projectV2Item { + id + } + } +} +``` + +### GraphQL: Update Field Value (Text) +```graphql +mutation { + updateProjectV2ItemFieldValue(input: { + projectId: "" + itemId: "" + fieldId: "" + value: { + text: "" + } + }) { + projectV2Item { + id + } + } +} +``` + +## Board Views (Planned — #77, #78, #79) + +Three views will be configured after fields are created: + +| View | Type | Primary Group | Purpose | +|------|------|---------------|---------| +| **Pipeline** | Board (kanban) | Status field | See issues flowing through pipeline stages | +| **Team Planning** | Table | Milestone + Phase | Sort/filter by milestone, route, assignee | +| **Roadmap** | Roadmap | GitHub Milestone dates | Timeline view of milestone delivery | + +## Dependencies + +| Issue | Title | Depends On | +|-------|-------|------------| +| #72 | Add mgw:board command | This schema (#71) | +| #73 | Sync project.json milestones into board | This schema (#71) | +| #74 | Auto-update board Status on pipeline_stage | This schema (#71) | +| #77 | Configure Board layout (kanban) | Board creation (#72) | +| #78 | Configure Table layout | Board creation (#72) | +| #79 | Configure Roadmap layout | Board creation (#72) | From b9d86a07ebc75d58bbf267f9f88d7442cddba86c Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:06:56 -0600 Subject: [PATCH 02/10] feat(board): add mgw:board command for project board management Implement new /mgw:board command with three subcommands: - create: idempotent board creation with 5 custom fields via GraphQL - show: display board state with items grouped by Status field - configure: compare field options against canonical schema Custom fields follow board schema from #71 (docs/BOARD-SCHEMA.md): - Status (SINGLE_SELECT, 13 options mapping 1:1 to pipeline_stage) - AI Agent State (TEXT, updated during GSD execution) - Milestone (TEXT, from project.json milestone name) - Phase (TEXT, phase number + name format) - GSD Route (SINGLE_SELECT, 4 options for all known routes) Board metadata (node_id, field IDs, option IDs) stored in project.json under project.project_board.fields for downstream commands to read without re-querying GitHub. Closes #72 Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/mgw/board.md | 972 ++++++++++++++++++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 .claude/commands/mgw/board.md 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 + From 86993e5e8e7aeff62d1a64eed48a13301c354d0c Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:12:14 -0600 Subject: [PATCH 03/10] feat(board): sync milestone issues onto board items on project init and extend Adds sync_milestone_to_board step to mgw:project command. After issues are created (both init and extend modes), each issue is added to the GitHub Projects v2 board as an item with Milestone, Phase, and GSD Route fields set. Board item IDs are stored in project.json per issue (board_item_id field). Non-blocking: any GraphQL failure logs a warning and the pipeline continues. Sync is skipped silently when board is not configured or custom fields are not set up. Closes #73 Co-Authored-By: Claude Sonnet 4.6 --- commands/project.md | 252 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 249 insertions(+), 3 deletions(-) diff --git a/commands/project.md b/commands/project.md index 35755ca..e25bd06 100644 --- a/commands/project.md +++ b/commands/project.md @@ -574,6 +574,228 @@ fi Store `PROJECT_NUMBER` and `PROJECT_URL` for inclusion in project.json and the summary report. + +**Sync newly created issues onto the board as items with field values (non-blocking):** + +This step runs after `create_project_board` (both init and extend modes). It adds each +newly created issue as a board item and sets Milestone, Phase, and GSD Route field values. +Board item IDs are collected here and stored in project.json (as `board_item_id` per issue). + +If no board is configured (PROJECT_NUMBER is empty) or the board has no custom fields +configured (node_id or fields missing from project.json), skip silently. + +Non-blocking: any GraphQL error is logged as a WARNING and does not halt the pipeline. + +```bash +# Load board field metadata from project.json +BOARD_NODE_ID=$(python3 -c " +import json +try: + p = json.load(open('${MGW_DIR}/project.json')) + print(p.get('project', {}).get('project_board', {}).get('node_id', '')) +except: + print('') +" 2>/dev/null || echo "") + +BOARD_FIELDS_JSON=$(python3 -c " +import json +try: + p = json.load(open('${MGW_DIR}/project.json')) + fields = p.get('project', {}).get('project_board', {}).get('fields', {}) + print(json.dumps(fields)) +except: + print('{}') +" 2>/dev/null || echo "{}") + +# Resolve field IDs from stored metadata +MILESTONE_FIELD_ID=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(fields.get('milestone', {}).get('field_id', '')) +" 2>/dev/null || echo "") + +PHASE_FIELD_ID=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(fields.get('phase', {}).get('field_id', '')) +" 2>/dev/null || echo "") + +GSD_ROUTE_FIELD_ID=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(fields.get('gsd_route', {}).get('field_id', '')) +" 2>/dev/null || echo "") + +GSD_ROUTE_OPTIONS=$(echo "$BOARD_FIELDS_JSON" | python3 -c " +import json,sys +fields = json.load(sys.stdin) +print(json.dumps(fields.get('gsd_route', {}).get('options', {}))) +" 2>/dev/null || echo "{}") + +# Determine if sync is possible +BOARD_SYNC_ENABLED=false +if [ -n "$PROJECT_NUMBER" ] && [ -n "$BOARD_NODE_ID" ]; then + BOARD_SYNC_ENABLED=true + echo "" + echo "Syncing ${TOTAL_ISSUES_CREATED} issues onto board #${PROJECT_NUMBER}..." +elif [ -n "$PROJECT_NUMBER" ] && [ -z "$BOARD_NODE_ID" ]; then + echo "" + echo "NOTE: Board #${PROJECT_NUMBER} exists but custom fields not configured." + echo " Run /mgw:board create to set up fields, then board sync will be available." +fi + +# ISSUE_RECORD format: "milestone_index:issue_number:title:phase_num:phase_name:gsd_route:depends_on" +# ITEM_ID_MAP accumulates: "issue_number:item_id" for project.json storage +ITEM_ID_MAP=() +BOARD_SYNC_WARNINGS=() + +if [ "$BOARD_SYNC_ENABLED" = "true" ]; then + for RECORD in "${ISSUE_RECORDS[@]}"; do + ISSUE_NUM=$(echo "$RECORD" | cut -d':' -f2) + ISSUE_PHASE_NUM=$(echo "$RECORD" | cut -d':' -f4) + ISSUE_PHASE_NAME=$(echo "$RECORD" | cut -d':' -f5) + ISSUE_GSD_ROUTE=$(echo "$RECORD" | cut -d':' -f6) + ISSUE_MILESTONE_IDX=$(echo "$RECORD" | cut -d':' -f1) + + # Get milestone name for this issue + ISSUE_MILESTONE_NAME=$(python3 -c " +import json +try: + d = json.load(open('/tmp/mgw-template.json')) + print(d['milestones'][${ISSUE_MILESTONE_IDX}]['name']) +except: + print('') +" 2>/dev/null || echo "") + + # Resolve GitHub issue node ID (needed for addProjectV2ItemById) + ISSUE_NODE_ID=$(gh api graphql -f query=' + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + } + ' -f owner="$OWNER" -f repo="$REPO_NAME" -F number="${ISSUE_NUM}" \ + --jq '.data.repository.issue.id' 2>/dev/null || echo "") + + if [ -z "$ISSUE_NODE_ID" ]; then + BOARD_SYNC_WARNINGS+=("WARNING: Could not resolve node ID for issue #${ISSUE_NUM} — skipping board sync for this issue") + continue + fi + + # Add issue to board + ADD_RESULT=$(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="$ISSUE_NODE_ID" 2>/dev/null) + + ITEM_ID=$(echo "$ADD_RESULT" | python3 -c " +import json,sys +try: + d = json.load(sys.stdin) + print(d['data']['addProjectV2ItemById']['item']['id']) +except: + print('') +" 2>/dev/null || echo "") + + if [ -z "$ITEM_ID" ]; then + BOARD_SYNC_WARNINGS+=("WARNING: Failed to add issue #${ISSUE_NUM} to board") + continue + fi + + echo " Added #${ISSUE_NUM} to board (item: ${ITEM_ID})" + ITEM_ID_MAP+=("${ISSUE_NUM}:${ITEM_ID}") + + # Set Milestone field (TEXT) + if [ -n "$MILESTONE_FIELD_ID" ] && [ -n "$ISSUE_MILESTONE_NAME" ]; then + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $value } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$MILESTONE_FIELD_ID" -f value="$ISSUE_MILESTONE_NAME" \ + 2>/dev/null || BOARD_SYNC_WARNINGS+=("WARNING: Failed to set Milestone field on board item for #${ISSUE_NUM}") + fi + + # Set Phase field (TEXT) — "Phase N: Phase Name" + if [ -n "$PHASE_FIELD_ID" ]; then + PHASE_DISPLAY="Phase ${ISSUE_PHASE_NUM}: ${ISSUE_PHASE_NAME}" + gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { text: $value } + }) { projectV2Item { id } } + } + ' -f projectId="$BOARD_NODE_ID" -f itemId="$ITEM_ID" \ + -f fieldId="$PHASE_FIELD_ID" -f value="$PHASE_DISPLAY" \ + 2>/dev/null || BOARD_SYNC_WARNINGS+=("WARNING: Failed to set Phase field on board item for #${ISSUE_NUM}") + fi + + # Set GSD Route field (SINGLE_SELECT) — look up option ID from stored map + if [ -n "$GSD_ROUTE_FIELD_ID" ]; then + # Map template gsd_route to board option key (e.g. "plan-phase" → "gsd:plan-phase") + # GSD_ROUTE_OPTIONS stores keys like "gsd:quick", "gsd:plan-phase", etc. + ROUTE_OPTION_ID=$(echo "$GSD_ROUTE_OPTIONS" | python3 -c " +import json,sys +opts = json.load(sys.stdin) +# Try exact match on gsd: prefix first, then plain match +route = '${ISSUE_GSD_ROUTE}' +for key, val in opts.items(): + if key == 'gsd:' + route or key == route: + print(val) + sys.exit(0) +# Fallback: plain match on the route name without prefix +for key, val in opts.items(): + if key.endswith(':' + route) or key == route: + print(val) + sys.exit(0) +print('') +" 2>/dev/null || echo "") + + if [ -n "$ROUTE_OPTION_ID" ]; then + 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="$GSD_ROUTE_FIELD_ID" -f optionId="$ROUTE_OPTION_ID" \ + 2>/dev/null || BOARD_SYNC_WARNINGS+=("WARNING: Failed to set GSD Route field on board item for #${ISSUE_NUM}") + fi + fi + done + + if [ ${#BOARD_SYNC_WARNINGS[@]} -gt 0 ]; then + echo "" + echo "Board sync warnings:" + for W in "${BOARD_SYNC_WARNINGS[@]}"; do + echo " $W" + done + fi + + BOARD_SYNC_COUNT=$((${#ITEM_ID_MAP[@]})) + echo " Board sync complete: ${BOARD_SYNC_COUNT}/${TOTAL_ISSUES_CREATED} issues synced" +fi +``` + + **Write .mgw/project.json with project state** @@ -607,6 +829,8 @@ for m_idx, milestone in enumerate(template_data['milestones']): # Find github number from SLUG_TO_NUMBER slug = slugify(issue['title'])[:40] gh_num = SLUG_TO_NUMBER_MAP.get(slug) + # Look up board_item_id from ITEM_ID_MAP if available + item_id = ITEM_ID_MAP_DICT.get(gh_num, None) issues_out.append({ "github_number": gh_num, "title": issue['title'], @@ -615,7 +839,8 @@ for m_idx, milestone in enumerate(template_data['milestones']): "gsd_route": phase.get('gsd_route', 'plan-phase'), "labels": issue.get('labels', []), "depends_on_slugs": issue.get('depends_on', []), - "pipeline_stage": "new" + "pipeline_stage": "new", + "board_item_id": item_id }) milestones_out.append({ @@ -658,7 +883,18 @@ import json, sys # Read template data from the validated generated file template_data = json.load(open('/tmp/mgw-template.json')) -# ... (construct from available bash variables) + +# Build ITEM_ID_MAP_DICT from bash ITEM_ID_MAP array ("issue_num:item_id" entries) +# This dict maps github_number (int) -> board_item_id (str) +ITEM_ID_MAP_DICT = {} +for entry in [x for x in '''${ITEM_ID_MAP[*]}'''.split() if ':' in x]: + parts = entry.split(':', 1) + try: + ITEM_ID_MAP_DICT[int(parts[0])] = parts[1] + except (ValueError, IndexError): + pass + +# ... (construct from available bash variables — see pseudocode above) PYEOF ``` @@ -666,6 +902,11 @@ The simplest implementation: build the JSON structure incrementally during the issue/milestone creation steps (maintaining bash arrays), then assemble them into a python3 dictionary and write with `json.dumps(indent=2)` at this step. +The `ITEM_ID_MAP` bash array (populated in `sync_milestone_to_board`) contains entries +in `"issue_number:board_item_id"` format. Decode it into `ITEM_ID_MAP_DICT` (as shown +above) and use it when building each issue record so `board_item_id` is stored. +If board sync was skipped (ITEM_ID_MAP is empty), `board_item_id` is null for all issues. + Note: use `GENERATED_TYPE` (read from `/tmp/mgw-template.json`) for the `template` field in project.json, not a hardcoded template name. @@ -770,7 +1011,12 @@ Warnings: - [ ] Slug-to-number mapping built during Pass 1b - [ ] Dependency labels applied (Pass 2) — blocked-by:#N on dependent issues - [ ] cross-refs.json updated with dependency entries -- [ ] .mgw/project.json written with full project state +- [ ] Board sync: if board configured (PROJECT_NUMBER + BOARD_NODE_ID in project.json), each new issue added as board item +- [ ] Board sync: Milestone, Phase, and GSD Route fields set on each board item where field IDs are available +- [ ] Board sync: board_item_id stored per issue in project.json (null if board sync skipped or failed) +- [ ] Board sync: non-blocking — GraphQL errors logged as warnings, pipeline continues +- [ ] Board sync: skipped silently if board not configured or custom fields not set up +- [ ] .mgw/project.json written with full project state (including board_item_id per issue) - [ ] Post-init summary displayed - [ ] Command does NOT trigger execution (PROJ-05) - [ ] Extend mode: all milestones complete detected, new milestones appended, existing data preserved From 8bce92622438f0b6a6dcacccf90f48f3d9aa954d Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:19:43 -0600 Subject: [PATCH 04/10] feat(board): auto-update board Status field on pipeline_stage transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add update_board_status shared utility (board-sync.md) and hook into issue.md and run.md stage transitions. All board updates are non-blocking — API failures never prevent pipeline execution. Reads field/option IDs from board-schema.json or project.json at runtime. Closes #74 Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/mgw/issue.md | 69 +++++++ .claude/commands/mgw/run.md | 88 ++++++++- .claude/commands/mgw/workflows/board-sync.md | 192 +++++++++++++++++++ .claude/commands/mgw/workflows/state.md | 1 + 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/mgw/workflows/board-sync.md 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) | From a9c74ced266493d7df339cb5d0bde4b67c824dbe Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:26:53 -0600 Subject: [PATCH 05/10] feat(board): surface AI Agent State field during GSD execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add update_board_agent_state utility to board-sync.md and hook into run.md at planner/executor/verifier spawn points for both quick and plan-phase routes. Field is cleared after PR creation. All calls are non-blocking — API failures never interrupt pipeline execution. Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/mgw/run.md | 151 ++++++++- .claude/commands/mgw/workflows/board-sync.md | 304 +++++++++++++++++++ 2 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/mgw/workflows/board-sync.md diff --git a/.claude/commands/mgw/run.md b/.claude/commands/mgw/run.md index 7a6a780..775eb17 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,11 @@ 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) +``` + Add cross-ref (at `${REPO_ROOT}/.mgw/cross-refs.json`): issue → PR. @@ -1052,6 +1194,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 +1237,9 @@ 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) +- [ ] 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..a4f69ce --- /dev/null +++ b/.claude/commands/mgw/workflows/board-sync.md @@ -0,0 +1,304 @@ + +Shared board sync utilities for MGW pipeline commands. Two 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. + +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 "" +``` + +## 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 | From 498091fd8ce9872d2ae7e9e8cc2dbc1f027f95f9 Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:33:49 -0600 Subject: [PATCH 06/10] feat(board): sync linked PRs onto board items via cross-refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sync_pr_to_board utility to workflows/board-sync.md that calls addProjectV2ItemById to add a PR as a GitHub Projects v2 board item - Hook sync_pr_to_board into run.md after PR creation (non-blocking) - Hook sync_pr_to_board into pr.md update_state step (linked mode only) - Add board_reconcile step to sync.md that iterates cross-refs and calls sync_pr_to_board for all issue→PR implements links (idempotent, non-blocking) - Add board-sync.md to execution_context of pr.md and sync.md - Pull forward board-sync.md, board.md, issue.md, and state.md from prior milestone board issues (#71-#75) as this branch is based on main Closes #76 Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/mgw/board.md | 972 +++++++++++++++++++ .claude/commands/mgw/issue.md | 69 ++ .claude/commands/mgw/pr.md | 7 + .claude/commands/mgw/run.md | 153 ++- .claude/commands/mgw/sync.md | 49 + .claude/commands/mgw/workflows/board-sync.md | 403 ++++++++ .claude/commands/mgw/workflows/state.md | 1 + 7 files changed, 1653 insertions(+), 1 deletion(-) create mode 100644 .claude/commands/mgw/board.md create mode 100644 .claude/commands/mgw/workflows/board-sync.md 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) | From 092d342a81b2b088730cb5d19d4f4b0e1623a737 Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:39:54 -0600 Subject: [PATCH 07/10] feat(board): configure Board layout view with pipeline-stage swimlanes Add mgw:board views subcommand supporting kanban, table, and roadmap layouts. Creates GitHub Projects v2 views via GraphQL and outputs user instructions for setting group-by configuration in the GitHub UI (API limitation). Create docs/BOARD-SCHEMA.md documenting custom fields, Status options, GSD route options, and the three intended layout views with their configuration. Closes #77 Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/mgw/board.md | 1200 +++++++++++++++++++++++++++++++++ docs/BOARD-SCHEMA.md | 163 +++++ 2 files changed, 1363 insertions(+) create mode 100644 .claude/commands/mgw/board.md create mode 100644 docs/BOARD-SCHEMA.md diff --git a/.claude/commands/mgw/board.md b/.claude/commands/mgw/board.md new file mode 100644 index 0000000..3187f49 --- /dev/null +++ b/.claude/commands/mgw/board.md @@ -0,0 +1,1200 @@ +--- +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. Four 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. Also shows configured views. +- `configure` — Updates board field options (add new pipeline stages, GSD routes, etc.) + based on the current board-schema definitions. +- `views` — Creates GitHub Projects v2 layout views (Board/Kanban, Table, Roadmap). + Subcommands: `views kanban`, `views table`, `views roadmap`. Creates the view and + outputs instructions for manual group-by configuration in the GitHub UI. + +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" + echo " views Create layout views (kanban, table, roadmap)" + exit 1 +fi + +case "$SUBCOMMAND" in + create|show|configure|views) ;; + *) + echo "Unknown subcommand: ${SUBCOMMAND}" + echo "Valid: create, show, configure, views" + 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 "" + + # Show configured views if any + VIEWS_JSON=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +views = board.get('views', {}) +print(json.dumps(views)) +" 2>/dev/null || echo "{}") + + if [ "$VIEWS_JSON" != "{}" ] && [ -n "$VIEWS_JSON" ]; then + echo "Configured Views:" + echo "$VIEWS_JSON" | python3 -c " +import json,sys +views = json.load(sys.stdin) +for key, v in views.items(): + print(f' {v[\"name\"]:<35} {v[\"layout\"]:<16} (ID: {v[\"view_id\"]})') +" 2>/dev/null + echo "" + else + echo "Views: none configured" + echo " Run /mgw:board views kanban to create the kanban view" + echo "" + fi + + 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 +``` + + + +**Execute 'views' subcommand:** + +Only run if `$SUBCOMMAND = "views"`. + +Creates GitHub Projects v2 layout views. Subcommand argument is the view type: +`kanban`, `table`, or `roadmap`. GitHub's API supports creating views but does NOT +support programmatic configuration of board grouping — that must be set in the UI. + +```bash +if [ "$SUBCOMMAND" = "views" ]; then + if [ "$BOARD_CONFIGURED" = "false" ]; then + echo "No board configured. Run /mgw:board create first." + exit 1 + fi + + VIEW_TYPE=$(echo "$ARGUMENTS" | awk '{print $2}') + + if [ -z "$VIEW_TYPE" ]; then + echo "Usage: /mgw:board views " + echo "" + echo " kanban Create Board layout view (swimlanes by Status)" + echo " table Create Table layout view (flat list with all fields)" + echo " roadmap Create Roadmap layout view (timeline by Milestone)" + exit 1 + fi + + case "$VIEW_TYPE" in + kanban|table|roadmap) ;; + *) + echo "Unknown view type: ${VIEW_TYPE}" + echo "Valid: kanban, table, roadmap" + exit 1 + ;; + esac +``` + +**Map view type to layout and name:** + +```bash + case "$VIEW_TYPE" in + kanban) + VIEW_NAME="Kanban — Pipeline Stages" + VIEW_LAYOUT="BOARD_LAYOUT" + VIEW_KEY="kanban" + ;; + table) + VIEW_NAME="All Issues" + VIEW_LAYOUT="TABLE_LAYOUT" + VIEW_KEY="table" + ;; + roadmap) + VIEW_NAME="Roadmap" + VIEW_LAYOUT="ROADMAP_LAYOUT" + VIEW_KEY="roadmap" + ;; + esac + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD VIEWS: ${VIEW_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "Creating ${VIEW_LAYOUT} view: '${VIEW_NAME}'..." + echo "" +``` + +**Create the view via GraphQL:** + +```bash + CREATE_VIEW_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!, $name: String!, $layout: ProjectV2ViewLayout!) { + createProjectV2View(input: { + projectId: $projectId + name: $name + layout: $layout + }) { + projectV2View { + id + name + layout + } + } + } + ' -f projectId="$BOARD_NODE_ID" \ + -f name="$VIEW_NAME" \ + -f layout="$VIEW_LAYOUT" 2>&1) + + VIEW_ID=$(echo "$CREATE_VIEW_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2View']['projectV2View']['id']) +" 2>/dev/null) + + VIEW_LAYOUT_RETURNED=$(echo "$CREATE_VIEW_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2View']['projectV2View']['layout']) +" 2>/dev/null) + + if [ -z "$VIEW_ID" ]; then + echo "ERROR: Failed to create view." + echo "GraphQL response: ${CREATE_VIEW_RESULT}" + exit 1 + fi + + echo "View created:" + echo " Name: ${VIEW_NAME}" + echo " Layout: ${VIEW_LAYOUT_RETURNED}" + echo " ID: ${VIEW_ID}" + echo "" +``` + +**Store view ID in project.json:** + +```bash + python3 << PYEOF +import json + +with open('${MGW_DIR}/project.json') as f: + project = json.load(f) + +# Ensure views dict exists under project_board +board = project.setdefault('project', {}).setdefault('project_board', {}) +views = board.setdefault('views', {}) + +views['${VIEW_KEY}'] = { + 'view_id': '${VIEW_ID}', + 'name': '${VIEW_NAME}', + 'layout': '${VIEW_LAYOUT}' +} + +with open('${MGW_DIR}/project.json', 'w') as f: + json.dump(project, f, indent=2) + +print('project.json updated with view ID') +PYEOF +``` + +**Output instructions and next steps:** + +```bash + echo "View ID stored in .mgw/project.json under project.project_board.views.${VIEW_KEY}" + echo "" + + case "$VIEW_TYPE" in + kanban) + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Group By in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "GitHub's API does not support setting board grouping programmatically." + echo "To create swimlanes by pipeline stage:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Select 'Group by' -> 'Status'" + echo "" + echo "Each pipeline stage will become a swimlane column:" + echo " New / Triaged / Planning / Executing / Verifying / PR Created / Done" + echo " + Needs Info / Needs Security Review / Discussing / Approved / Failed / Blocked" + echo "" + echo "See docs/BOARD-SCHEMA.md for full view configuration reference." + ;; + table) + echo "Table view created. Recommended configuration in GitHub UI:" + echo " 1. Open: ${BOARD_URL}" + echo " 2. Select '${VIEW_NAME}' tab" + echo " 3. Sort by 'Status' ascending to surface active work at top" + echo "" + echo "See docs/BOARD-SCHEMA.md for full view configuration reference." + ;; + roadmap) + echo "Roadmap view created. Recommended configuration in GitHub UI:" + echo " 1. Open: ${BOARD_URL}" + echo " 2. Select '${VIEW_NAME}' tab" + echo " 3. Set date fields and group by 'Milestone'" + echo "" + echo "See docs/BOARD-SCHEMA.md for full view configuration reference." + ;; + esac + +fi # end views 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 +- [ ] show: displays configured views (name, layout, ID) if any views exist +- [ ] show: prompts to run views kanban if no views are configured +- [ ] views: board not configured → clear error message +- [ ] views: no view type argument → usage message listing kanban, table, roadmap +- [ ] views: unknown view type → clear error message +- [ ] views: createProjectV2View mutation succeeds — view ID captured +- [ ] views: view ID stored in project.json under project.project_board.views +- [ ] views kanban: outputs step-by-step instructions for setting Group by Status in GitHub UI +- [ ] views kanban: lists all 13 pipeline stage columns user will see after configuring +- [ ] views table: outputs instructions for sorting by Status +- [ ] views roadmap: outputs instructions for date fields and milestone grouping +- [ ] views: references docs/BOARD-SCHEMA.md for full view configuration documentation + diff --git a/docs/BOARD-SCHEMA.md b/docs/BOARD-SCHEMA.md new file mode 100644 index 0000000..808a6ad --- /dev/null +++ b/docs/BOARD-SCHEMA.md @@ -0,0 +1,163 @@ +# MGW Board Schema + +This document describes the GitHub Projects v2 board structure used by MGW, including +custom fields, option values, and layout views. + +--- + +## Overview + +The MGW pipeline board is a GitHub Projects v2 project created by `/mgw:board create`. +It tracks all issues managed by the MGW pipeline, with custom fields reflecting the +pipeline state stored in `.mgw/active/` and `.mgw/project.json`. + +--- + +## Custom Fields + +| Field Name | Type | Description | +|------------|------|-------------| +| Status | SINGLE_SELECT | Maps to `pipeline_stage` in issue state | +| AI Agent State | TEXT | Current GSD agent activity or last action | +| Milestone | TEXT | Milestone title (e.g. "v2 — GitHub Projects Board Management") | +| Phase | TEXT | Phase number and name (e.g. "15 — Multi-Layout Views") | +| GSD Route | SINGLE_SELECT | GSD execution route for the issue | + +--- + +## Status Field Options + +The Status field maps directly to MGW `pipeline_stage` values. The 13 options are: + +| Option Name | Color | pipeline_stage | Description | +|-------------|-------|---------------|-------------| +| New | GRAY | `new` | Issue created, not yet triaged | +| Triaged | BLUE | `triaged` | Triage complete, ready for execution | +| Needs Info | YELLOW | `needs-info` | Blocked at triage gate — needs more detail | +| Needs Security Review | RED | `needs-security-review` | High security risk flagged | +| Discussing | PURPLE | `discussing` | Awaiting stakeholder scope approval | +| Approved | GREEN | `approved` | Discussion complete, cleared for execution | +| Planning | BLUE | `planning` | GSD planner agent active | +| Executing | ORANGE | `executing` | GSD executor agent active | +| Verifying | BLUE | `verifying` | GSD verifier agent active | +| PR Created | GREEN | `pr-created` | PR open, awaiting review | +| Done | GREEN | `done` | PR merged, issue closed | +| Failed | RED | `failed` | Unrecoverable pipeline error | +| Blocked | RED | `blocked` | Blocking comment detected | + +--- + +## GSD Route Field Options + +| Option Name | Route | Description | +|-------------|-------|-------------| +| quick | `gsd:quick` | Small/atomic task, direct execution | +| quick --full | `gsd:quick --full` | Small task with plan-checker and verifier | +| plan-phase | `gsd:plan-phase` | Medium task with phase planning | +| new-milestone | `gsd:new-milestone` | Large task with full milestone lifecycle | + +--- + +## Views + +GitHub Projects v2 supports multiple layout views: Board (kanban), Table, and Roadmap. +MGW creates and configures these views using `/mgw:board views`. + +### Intended Views + +| View Name | Layout | Group By | Sort By | Purpose | +|-----------|--------|----------|---------|---------| +| Kanban — Pipeline Stages | BOARD_LAYOUT | Status | — | Swimlane view per pipeline stage | +| All Issues | TABLE_LAYOUT | — | Status (asc) | Flat list of all items with all fields visible | +| Roadmap | ROADMAP_LAYOUT | — | Milestone | Timeline view for milestone planning | + +### View Configuration Notes + +**Kanban — Pipeline Stages (Board Layout)** + +- Created by `/mgw:board views kanban` +- Group By must be set to "Status" in the GitHub Projects UI after creation +- Each pipeline stage becomes a swimlane column +- GitHub's API does not support programmatic configuration of board grouping — + use the view's settings menu in the GitHub UI after the view is created + +**All Issues (Table Layout)** + +- Created by `/mgw:board views table` +- Shows all custom fields as columns +- Sort by Status ascending to see active work at top + +**Roadmap** + +- Created by `/mgw:board views roadmap` +- Requires start date and target date fields on items for full timeline view +- Group by Milestone to see milestone-level progress + +--- + +## API Reference + +### Creating Views + +```graphql +mutation { + createProjectV2View(input: { + projectId: $projectId + name: "Kanban — Pipeline Stages" + layout: BOARD_LAYOUT + }) { + projectV2View { id name layout } + } +} +``` + +Valid layout values: `BOARD_LAYOUT`, `TABLE_LAYOUT`, `ROADMAP_LAYOUT` + +### Limitation: Board Grouping + +GitHub's Projects v2 GraphQL API does not expose a mutation for setting the +"Group by" field on a board view. The grouping (which field creates swimlanes) +must be configured manually in the GitHub UI: + +1. Open the board at the project URL +2. Click the view name to open view settings +3. Set "Group by: Status" + +This limitation is documented in the GitHub Projects v2 API changelog. +The `/mgw:board views` command creates the view and outputs these instructions. + +--- + +## Storage + +Board metadata is stored in `.mgw/project.json` under `project.project_board`: + +```json +{ + "project": { + "project_board": { + "number": 1, + "url": "https://github.com/orgs/owner/projects/1", + "node_id": "PVT_...", + "fields": { + "status": { + "field_id": "PVTSSF_...", + "field_name": "Status", + "type": "SINGLE_SELECT", + "options": { + "new": "option-id-1", + "triaged": "option-id-2" + } + } + }, + "views": { + "kanban": { + "view_id": "PVTV_...", + "name": "Kanban — Pipeline Stages", + "layout": "BOARD_LAYOUT" + } + } + } + } +} +``` From 948169627eb554d4602c49a683717f9dd34867be Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:45:01 -0600 Subject: [PATCH 08/10] feat(board): configure Table layout view for team triage planning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename table view from "All Issues" to "Triage Table — Team Planning" - Add step-by-step column configuration instructions for triage planning - Column order: Status, Milestone, Phase, GSD Route, AI Agent State - Sort by Status ascending surfaces active work at top - Update BOARD-SCHEMA.md with triage table column order and sort config - Document table view entry in project.json storage schema Closes #78 Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/mgw/board.md | 36 ++++++++++++++++++++++++++++------- docs/BOARD-SCHEMA.md | 26 +++++++++++++++++++++---- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.claude/commands/mgw/board.md b/.claude/commands/mgw/board.md index 3187f49..9bfc1e2 100644 --- a/.claude/commands/mgw/board.md +++ b/.claude/commands/mgw/board.md @@ -1021,7 +1021,7 @@ if [ "$SUBCOMMAND" = "views" ]; then VIEW_KEY="kanban" ;; table) - VIEW_NAME="All Issues" + VIEW_NAME="Triage Table — Team Planning" VIEW_LAYOUT="TABLE_LAYOUT" VIEW_KEY="table" ;; @@ -1140,12 +1140,31 @@ PYEOF echo "See docs/BOARD-SCHEMA.md for full view configuration reference." ;; table) - echo "Table view created. Recommended configuration in GitHub UI:" - echo " 1. Open: ${BOARD_URL}" - echo " 2. Select '${VIEW_NAME}' tab" - echo " 3. Sort by 'Status' ascending to surface active work at top" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Columns in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - echo "See docs/BOARD-SCHEMA.md for full view configuration reference." + echo "Triage Table view created for team planning visibility." + echo "GitHub's API does not support setting table columns or sort order" + echo "programmatically — configure in the GitHub UI:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Add these columns in order:" + echo " Status (sort ascending — pipeline order)" + echo " Milestone" + echo " Phase" + echo " GSD Route" + echo " AI Agent State" + echo " 5. Set 'Sort by' -> 'Status' ascending" + echo "" + echo "This column order surfaces triage planning context:" + echo " Status first shows pipeline position at a glance." + echo " Milestone + Phase + GSD Route give scope and routing context." + echo " AI Agent State shows live execution activity." + echo "" + echo "See docs/BOARD-SCHEMA.md for full column and sort configuration reference." ;; roadmap) echo "Roadmap view created. Recommended configuration in GitHub UI:" @@ -1194,7 +1213,10 @@ fi # end views subcommand - [ ] views: view ID stored in project.json under project.project_board.views - [ ] views kanban: outputs step-by-step instructions for setting Group by Status in GitHub UI - [ ] views kanban: lists all 13 pipeline stage columns user will see after configuring -- [ ] views table: outputs instructions for sorting by Status +- [ ] views table: view name is "Triage Table — Team Planning" (not generic "All Issues") +- [ ] views table: outputs step-by-step instructions for adding triage planning columns in GitHub UI +- [ ] views table: column order is Status, Milestone, Phase, GSD Route, AI Agent State +- [ ] views table: outputs instructions for sorting by Status ascending - [ ] views roadmap: outputs instructions for date fields and milestone grouping - [ ] views: references docs/BOARD-SCHEMA.md for full view configuration documentation diff --git a/docs/BOARD-SCHEMA.md b/docs/BOARD-SCHEMA.md index 808a6ad..84bf7cb 100644 --- a/docs/BOARD-SCHEMA.md +++ b/docs/BOARD-SCHEMA.md @@ -68,7 +68,7 @@ MGW creates and configures these views using `/mgw:board views`. | View Name | Layout | Group By | Sort By | Purpose | |-----------|--------|----------|---------|---------| | Kanban — Pipeline Stages | BOARD_LAYOUT | Status | — | Swimlane view per pipeline stage | -| All Issues | TABLE_LAYOUT | — | Status (asc) | Flat list of all items with all fields visible | +| Triage Table — Team Planning | TABLE_LAYOUT | — | Status (asc) | Triage planning surface sorted by pipeline status | | Roadmap | ROADMAP_LAYOUT | — | Milestone | Timeline view for milestone planning | ### View Configuration Notes @@ -81,11 +81,24 @@ MGW creates and configures these views using `/mgw:board views`. - GitHub's API does not support programmatic configuration of board grouping — use the view's settings menu in the GitHub UI after the view is created -**All Issues (Table Layout)** +**Triage Table — Team Planning (Table Layout)** - Created by `/mgw:board views table` -- Shows all custom fields as columns -- Sort by Status ascending to see active work at top +- Primary planning surface for team triage and routing visibility +- Column order for triage planning (configure in GitHub Projects UI): + + | Order | Column | Purpose | + |-------|--------|---------| + | 1 | Status | Pipeline position — sort ascending for pipeline order | + | 2 | Milestone | Which milestone the issue belongs to | + | 3 | Phase | Phase number and name within the milestone | + | 4 | GSD Route | Execution route (quick, plan-phase, new-milestone) | + | 5 | AI Agent State | Live agent activity or last action | + +- Sort By: **Status ascending** — surfaces active work (Executing, Planning, Verifying) + at top, done work (Done, PR Created) at bottom +- GitHub's API does not support setting column order or sort programmatically — + configure via the view settings menu in the GitHub UI after creation **Roadmap** @@ -155,6 +168,11 @@ Board metadata is stored in `.mgw/project.json` under `project.project_board`: "view_id": "PVTV_...", "name": "Kanban — Pipeline Stages", "layout": "BOARD_LAYOUT" + }, + "table": { + "view_id": "PVTV_...", + "name": "Triage Table — Team Planning", + "layout": "TABLE_LAYOUT" } } } From 177e9e118336c17891a428f932cb2132a2d9ef7e Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sat, 28 Feb 2026 23:52:05 -0600 Subject: [PATCH 09/10] feat(board): configure Roadmap layout view with milestone-based timeline grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add mgw:board views roadmap subcommand — creates ROADMAP_LAYOUT view named "Roadmap — Milestone Timeline" via createProjectV2View GraphQL mutation - View dispatch updated: roadmap case delivers milestone grouping instructions and documents date field limitation (MGW uses iteration-based tracking) - Document milestone due date workaround for timeline bar rendering - Store roadmap view ID in project.json alongside kanban and table entries - Update docs/BOARD-SCHEMA.md with full roadmap view configuration reference: - Group by Milestone step-by-step UI instructions - Date field limitation explanation and gh api workaround - Updated views table and storage schema with roadmap entry - Completes the views subcommand: kanban + table + roadmap all implemented Closes #79 Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/mgw/board.md | 1245 +++++++++++++++++++++++++++++++++ docs/BOARD-SCHEMA.md | 226 ++++++ 2 files changed, 1471 insertions(+) create mode 100644 .claude/commands/mgw/board.md create mode 100644 docs/BOARD-SCHEMA.md diff --git a/.claude/commands/mgw/board.md b/.claude/commands/mgw/board.md new file mode 100644 index 0000000..4131869 --- /dev/null +++ b/.claude/commands/mgw/board.md @@ -0,0 +1,1245 @@ +--- +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. Four 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. Also shows configured views. +- `configure` — Updates board field options (add new pipeline stages, GSD routes, etc.) + based on the current board-schema definitions. +- `views` — Creates GitHub Projects v2 layout views (Board/Kanban, Table, Roadmap). + Subcommands: `views kanban`, `views table`, `views roadmap`. Creates the view and + outputs instructions for manual group-by configuration in the GitHub UI. + +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" + echo " views Create layout views (kanban, table, roadmap)" + exit 1 +fi + +case "$SUBCOMMAND" in + create|show|configure|views) ;; + *) + echo "Unknown subcommand: ${SUBCOMMAND}" + echo "Valid: create, show, configure, views" + 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 "" + + # Show configured views if any + VIEWS_JSON=$(echo "$PROJECT_JSON" | python3 -c " +import json,sys +p = json.load(sys.stdin) +board = p.get('project', {}).get('project_board', {}) +views = board.get('views', {}) +print(json.dumps(views)) +" 2>/dev/null || echo "{}") + + if [ "$VIEWS_JSON" != "{}" ] && [ -n "$VIEWS_JSON" ]; then + echo "Configured Views:" + echo "$VIEWS_JSON" | python3 -c " +import json,sys +views = json.load(sys.stdin) +for key, v in views.items(): + print(f' {v[\"name\"]:<40} {v[\"layout\"]:<16} (ID: {v[\"view_id\"]})') +" 2>/dev/null + echo "" + else + echo "Views: none configured" + echo " Run /mgw:board views kanban to create the kanban view" + echo "" + fi + + 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 +``` + + + +**Execute 'views' subcommand:** + +Only run if `$SUBCOMMAND = "views"`. + +Creates GitHub Projects v2 layout views. Subcommand argument is the view type: +`kanban`, `table`, or `roadmap`. GitHub's API supports creating views but does NOT +support programmatic configuration of board grouping — that must be set in the UI. + +```bash +if [ "$SUBCOMMAND" = "views" ]; then + if [ "$BOARD_CONFIGURED" = "false" ]; then + echo "No board configured. Run /mgw:board create first." + exit 1 + fi + + VIEW_TYPE=$(echo "$ARGUMENTS" | awk '{print $2}') + + if [ -z "$VIEW_TYPE" ]; then + echo "Usage: /mgw:board views " + echo "" + echo " kanban Create Board layout view (swimlanes by Status)" + echo " table Create Table layout view (flat list with all fields)" + echo " roadmap Create Roadmap layout view (timeline grouped by Milestone)" + exit 1 + fi + + case "$VIEW_TYPE" in + kanban|table|roadmap) ;; + *) + echo "Unknown view type: ${VIEW_TYPE}" + echo "Valid: kanban, table, roadmap" + exit 1 + ;; + esac +``` + +**Map view type to layout and name:** + +```bash + case "$VIEW_TYPE" in + kanban) + VIEW_NAME="Kanban — Pipeline Stages" + VIEW_LAYOUT="BOARD_LAYOUT" + VIEW_KEY="kanban" + ;; + table) + VIEW_NAME="Triage Table — Team Planning" + VIEW_LAYOUT="TABLE_LAYOUT" + VIEW_KEY="table" + ;; + roadmap) + VIEW_NAME="Roadmap — Milestone Timeline" + VIEW_LAYOUT="ROADMAP_LAYOUT" + VIEW_KEY="roadmap" + ;; + esac + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " MGW ► BOARD VIEWS: ${VIEW_NAME}" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}" + echo "Creating ${VIEW_LAYOUT} view: '${VIEW_NAME}'..." + echo "" +``` + +**Create the view via GraphQL:** + +```bash + CREATE_VIEW_RESULT=$(gh api graphql -f query=' + mutation($projectId: ID!, $name: String!, $layout: ProjectV2ViewLayout!) { + createProjectV2View(input: { + projectId: $projectId + name: $name + layout: $layout + }) { + projectV2View { + id + name + layout + } + } + } + ' -f projectId="$BOARD_NODE_ID" \ + -f name="$VIEW_NAME" \ + -f layout="$VIEW_LAYOUT" 2>&1) + + VIEW_ID=$(echo "$CREATE_VIEW_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2View']['projectV2View']['id']) +" 2>/dev/null) + + VIEW_LAYOUT_RETURNED=$(echo "$CREATE_VIEW_RESULT" | python3 -c " +import json,sys +d = json.load(sys.stdin) +print(d['data']['createProjectV2View']['projectV2View']['layout']) +" 2>/dev/null) + + if [ -z "$VIEW_ID" ]; then + echo "ERROR: Failed to create view." + echo "GraphQL response: ${CREATE_VIEW_RESULT}" + exit 1 + fi + + echo "View created:" + echo " Name: ${VIEW_NAME}" + echo " Layout: ${VIEW_LAYOUT_RETURNED}" + echo " ID: ${VIEW_ID}" + echo "" +``` + +**Store view ID in project.json:** + +```bash + python3 << PYEOF +import json + +with open('${MGW_DIR}/project.json') as f: + project = json.load(f) + +# Ensure views dict exists under project_board +board = project.setdefault('project', {}).setdefault('project_board', {}) +views = board.setdefault('views', {}) + +views['${VIEW_KEY}'] = { + 'view_id': '${VIEW_ID}', + 'name': '${VIEW_NAME}', + 'layout': '${VIEW_LAYOUT}' +} + +with open('${MGW_DIR}/project.json', 'w') as f: + json.dump(project, f, indent=2) + +print('project.json updated with view ID') +PYEOF +``` + +**Output instructions and next steps:** + +```bash + echo "View ID stored in .mgw/project.json under project.project_board.views.${VIEW_KEY}" + echo "" + + case "$VIEW_TYPE" in + kanban) + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Group By in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "GitHub's API does not support setting board grouping programmatically." + echo "To create swimlanes by pipeline stage:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Select 'Group by' -> 'Status'" + echo "" + echo "Each pipeline stage will become a swimlane column:" + echo " New / Triaged / Planning / Executing / Verifying / PR Created / Done" + echo " + Needs Info / Needs Security Review / Discussing / Approved / Failed / Blocked" + echo "" + echo "See docs/BOARD-SCHEMA.md for full view configuration reference." + ;; + table) + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Columns in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Triage Table view created for team planning visibility." + echo "GitHub's API does not support setting table columns or sort order" + echo "programmatically — configure in the GitHub UI:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Add these columns in order:" + echo " Status (sort ascending — pipeline order)" + echo " Milestone" + echo " Phase" + echo " GSD Route" + echo " AI Agent State" + echo " 5. Set 'Sort by' -> 'Status' ascending" + echo "" + echo "This column order surfaces triage planning context:" + echo " Status first shows pipeline position at a glance." + echo " Milestone + Phase + GSD Route give scope and routing context." + echo " AI Agent State shows live execution activity." + echo "" + echo "See docs/BOARD-SCHEMA.md for full column and sort configuration reference." + ;; + roadmap) + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " NEXT STEP: Configure Roadmap in GitHub UI" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Roadmap view created for milestone-based timeline visualization." + echo "GitHub's API does not support setting roadmap grouping or date fields" + echo "programmatically — configure in the GitHub UI:" + echo "" + echo " 1. Open the board: ${BOARD_URL}" + echo " 2. Click '${VIEW_NAME}' in the view tabs" + echo " 3. Click the view settings (down-arrow next to view name)" + echo " 4. Set 'Group by' -> 'Milestone'" + echo " Items will be grouped by the Milestone field value." + echo "" + echo "Timeline date field limitation:" + echo " GitHub Roadmap requires date fields (start date + end date) to render" + echo " items on the timeline. MGW uses iteration-based tracking without" + echo " explicit date fields — items will appear in the roadmap grouped by" + echo " Milestone but without timeline bars unless date fields are added." + echo "" + echo " To enable timeline bars, set milestone due dates via:" + echo " gh api repos/{owner}/{repo}/milestones/{number} --method PATCH \\" + echo " -f due_on='YYYY-MM-DDT00:00:00Z'" + echo " GitHub Projects v2 can read milestone due dates as a date source." + echo "" + echo "See docs/BOARD-SCHEMA.md for full roadmap configuration reference." + ;; + esac + +fi # end views 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 +- [ ] show: displays configured views (name, layout, ID) if any views exist +- [ ] show: prompts to run views kanban if no views are configured +- [ ] views: board not configured → clear error message +- [ ] views: no view type argument → usage message listing kanban, table, roadmap +- [ ] views: unknown view type → clear error message +- [ ] views: createProjectV2View mutation succeeds — view ID captured +- [ ] views: view ID stored in project.json under project.project_board.views +- [ ] views kanban: outputs step-by-step instructions for setting Group by Status in GitHub UI +- [ ] views kanban: lists all 13 pipeline stage columns user will see after configuring +- [ ] views table: view name is "Triage Table — Team Planning" +- [ ] views table: outputs step-by-step instructions for adding triage planning columns in GitHub UI +- [ ] views table: column order is Status, Milestone, Phase, GSD Route, AI Agent State +- [ ] views table: outputs instructions for sorting by Status ascending +- [ ] views roadmap: view name is "Roadmap — Milestone Timeline" +- [ ] views roadmap: outputs step-by-step instructions for setting Group by Milestone in GitHub UI +- [ ] views roadmap: explains date field limitation — MGW uses iteration-based tracking without explicit dates +- [ ] views roadmap: documents milestone due date workaround via gh api PATCH +- [ ] views: references docs/BOARD-SCHEMA.md for full view configuration documentation + diff --git a/docs/BOARD-SCHEMA.md b/docs/BOARD-SCHEMA.md new file mode 100644 index 0000000..5c8880c --- /dev/null +++ b/docs/BOARD-SCHEMA.md @@ -0,0 +1,226 @@ +# MGW Board Schema + +This document describes the GitHub Projects v2 board structure used by MGW, including +custom fields, option values, and layout views. + +--- + +## Overview + +The MGW pipeline board is a GitHub Projects v2 project created by `/mgw:board create`. +It tracks all issues managed by the MGW pipeline, with custom fields reflecting the +pipeline state stored in `.mgw/active/` and `.mgw/project.json`. + +--- + +## Custom Fields + +| Field Name | Type | Description | +|------------|------|-------------| +| Status | SINGLE_SELECT | Maps to `pipeline_stage` in issue state | +| AI Agent State | TEXT | Current GSD agent activity or last action | +| Milestone | TEXT | Milestone title (e.g. "v2 — GitHub Projects Board Management") | +| Phase | TEXT | Phase number and name (e.g. "15 — Multi-Layout Views") | +| GSD Route | SINGLE_SELECT | GSD execution route for the issue | + +--- + +## Status Field Options + +The Status field maps directly to MGW `pipeline_stage` values. The 13 options are: + +| Option Name | Color | pipeline_stage | Description | +|-------------|-------|---------------|-------------| +| New | GRAY | `new` | Issue created, not yet triaged | +| Triaged | BLUE | `triaged` | Triage complete, ready for execution | +| Needs Info | YELLOW | `needs-info` | Blocked at triage gate — needs more detail | +| Needs Security Review | RED | `needs-security-review` | High security risk flagged | +| Discussing | PURPLE | `discussing` | Awaiting stakeholder scope approval | +| Approved | GREEN | `approved` | Discussion complete, cleared for execution | +| Planning | BLUE | `planning` | GSD planner agent active | +| Executing | ORANGE | `executing` | GSD executor agent active | +| Verifying | BLUE | `verifying` | GSD verifier agent active | +| PR Created | GREEN | `pr-created` | PR open, awaiting review | +| Done | GREEN | `done` | PR merged, issue closed | +| Failed | RED | `failed` | Unrecoverable pipeline error | +| Blocked | RED | `blocked` | Blocking comment detected | + +--- + +## GSD Route Field Options + +| Option Name | Route | Description | +|-------------|-------|-------------| +| quick | `gsd:quick` | Small/atomic task, direct execution | +| quick --full | `gsd:quick --full` | Small task with plan-checker and verifier | +| plan-phase | `gsd:plan-phase` | Medium task with phase planning | +| new-milestone | `gsd:new-milestone` | Large task with full milestone lifecycle | + +--- + +## Views + +GitHub Projects v2 supports multiple layout views: Board (kanban), Table, and Roadmap. +MGW creates and configures these views using `/mgw:board views`. + +### Intended Views + +| View Name | Layout | Group By | Sort By | Purpose | +|-----------|--------|----------|---------|---------| +| Kanban — Pipeline Stages | BOARD_LAYOUT | Status | — | Swimlane view per pipeline stage | +| Triage Table — Team Planning | TABLE_LAYOUT | — | Status (asc) | Triage planning surface sorted by pipeline status | +| Roadmap — Milestone Timeline | ROADMAP_LAYOUT | Milestone | — | Timeline visualization grouped by milestone | + +### View Configuration Notes + +**Kanban — Pipeline Stages (Board Layout)** + +- Created by `/mgw:board views kanban` +- Group By must be set to "Status" in the GitHub Projects UI after creation +- Each pipeline stage becomes a swimlane column +- GitHub's API does not support programmatic configuration of board grouping — + use the view's settings menu in the GitHub UI after the view is created + +**Triage Table — Team Planning (Table Layout)** + +- Created by `/mgw:board views table` +- Primary planning surface for team triage and routing visibility +- Column order for triage planning (configure in GitHub Projects UI): + + | Order | Column | Purpose | + |-------|--------|---------| + | 1 | Status | Pipeline position — sort ascending for pipeline order | + | 2 | Milestone | Which milestone the issue belongs to | + | 3 | Phase | Phase number and name within the milestone | + | 4 | GSD Route | Execution route (quick, plan-phase, new-milestone) | + | 5 | AI Agent State | Live agent activity or last action | + +- Sort By: **Status ascending** — surfaces active work (Executing, Planning, Verifying) + at top, done work (Done, PR Created) at bottom +- GitHub's API does not support setting column order or sort programmatically — + configure via the view settings menu in the GitHub UI after creation + +**Roadmap — Milestone Timeline (Roadmap Layout)** + +- Created by `/mgw:board views roadmap` +- View name: "Roadmap — Milestone Timeline" +- Purpose: leadership and planning overview of the full project lifecycle by milestone + +**Roadmap grouping (configure in GitHub Projects UI after creation):** + +1. Open the board and click the "Roadmap — Milestone Timeline" view tab +2. Click the view settings (down-arrow next to the view name) +3. Set "Group by" -> "Milestone" + +Items will be grouped by the Milestone field value, showing all issues belonging to +each milestone as a row group in the roadmap. + +**Timeline date field limitation:** + +GitHub Roadmap layout requires date fields (start date + target date) assigned to +each item to render timeline bars. MGW uses iteration-based milestone tracking and +does not define explicit start/end date fields on board items by default. + +As a result: +- Items appear in the roadmap grouped by Milestone (grouping works correctly) +- Timeline bars are not displayed without date fields configured + +**Workaround — milestone due dates as timeline anchor:** + +GitHub Projects v2 can read GitHub milestone due dates as a date source. To enable +timeline bar rendering, set due dates on milestones via the API: + +```bash +gh api repos/{owner}/{repo}/milestones/{number} \ + --method PATCH \ + -f due_on='YYYY-MM-DDT00:00:00Z' +``` + +Once milestone due dates are set, configure the roadmap's date fields in the GitHub UI: +- Date field: "Target Date" -> milestone due date + +This gives the roadmap a target anchor per milestone without requiring issue-level +date fields in the MGW schema. + +--- + +## API Reference + +### Creating Views + +```graphql +mutation { + createProjectV2View(input: { + projectId: $projectId + name: "Roadmap — Milestone Timeline" + layout: ROADMAP_LAYOUT + }) { + projectV2View { id name layout } + } +} +``` + +Valid layout values: `BOARD_LAYOUT`, `TABLE_LAYOUT`, `ROADMAP_LAYOUT` + +### Limitation: Board Grouping and Date Fields + +GitHub's Projects v2 GraphQL API does not expose mutations for: +- Setting the "Group by" field on any view layout +- Configuring date fields for roadmap timeline bars + +Both must be configured manually in the GitHub UI: + +1. Open the board at the project URL +2. Click the view name to open view settings +3. For kanban: set "Group by: Status" +4. For roadmap: set "Group by: Milestone" +5. For roadmap: configure date fields under "Date fields" settings + +This limitation is documented in the GitHub Projects v2 API changelog. +The `/mgw:board views` command creates the view and outputs these instructions. + +--- + +## Storage + +Board metadata is stored in `.mgw/project.json` under `project.project_board`: + +```json +{ + "project": { + "project_board": { + "number": 1, + "url": "https://github.com/orgs/owner/projects/1", + "node_id": "PVT_...", + "fields": { + "status": { + "field_id": "PVTSSF_...", + "field_name": "Status", + "type": "SINGLE_SELECT", + "options": { + "new": "option-id-1", + "triaged": "option-id-2" + } + } + }, + "views": { + "kanban": { + "view_id": "PVTV_...", + "name": "Kanban — Pipeline Stages", + "layout": "BOARD_LAYOUT" + }, + "table": { + "view_id": "PVTV_...", + "name": "Triage Table — Team Planning", + "layout": "TABLE_LAYOUT" + }, + "roadmap": { + "view_id": "PVTV_...", + "name": "Roadmap — Milestone Timeline", + "layout": "ROADMAP_LAYOUT" + } + } + } + } +} +``` From 80056c513712826486d1b39f1c03de5f212e2f35 Mon Sep 17 00:00:00 2001 From: Stephen Miller Date: Sun, 1 Mar 2026 02:51:39 -0600 Subject: [PATCH 10/10] =?UTF-8?q?Add=20mgw:assign=20command=20=E2=80=94=20?= =?UTF-8?q?claim=20issues=20and=20update=20board=20assignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #80: new MGW command for claiming GitHub issues. - Assigns issue via gh issue edit --add-assignee (defaults to @me) - Updates .mgw/active/ state file with assignee field - Creates minimal state entry if issue has not been triaged yet - Emits board URL confirmation if GitHub Projects board is configured - Idempotent: re-assigning same user confirms state without error - Follows delegation boundary: only state and GitHub API operations GitHub Projects v2 automatically syncs issue assignees to board items, so no direct GraphQL mutation is needed for the board Assignee field. Co-Authored-By: Claude Sonnet 4.6 --- commands/assign.md | 300 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 commands/assign.md diff --git a/commands/assign.md b/commands/assign.md new file mode 100644 index 0000000..33e73d0 --- /dev/null +++ b/commands/assign.md @@ -0,0 +1,300 @@ +--- +name: mgw:assign +description: Claim an issue for a user — assigns via GitHub and updates board + state +argument-hint: " [username]" +allowed-tools: + - Bash + - Read + - Write + - Edit +--- + + +Claim a GitHub issue for yourself or another team member. Three operations in one call: + +1. **GitHub assignment** — `gh issue edit --add-assignee` to set the issue assignee +2. **State update** — write assignee to `.mgw/active/.json` (creates minimal entry + if not yet triaged) +3. **Board confirmation** — if a board is configured, emit the board URL so the team + can verify the assignment is reflected on the board item + +Usage: +- `mgw:assign 42` — assign issue #42 to yourself (@me) +- `mgw:assign 42 alice` — assign issue #42 to @alice + +GitHub Projects v2 automatically syncs issue assignees to board items, so no direct +GraphQL mutation is needed for the board Assignees field. + +Follows delegation boundary: only state and GitHub operations — no application code reads. + + + +@~/.claude/commands/mgw/workflows/state.md +@~/.claude/commands/mgw/workflows/github.md +@~/.claude/commands/mgw/workflows/board-sync.md + + + +Arguments: $ARGUMENTS + +State: .mgw/active/ (issue state — created if missing) +Board: .mgw/project.json (if configured — read for board URL only) + + + + + +**Parse $ARGUMENTS into issue number and optional username:** + +```bash +ISSUE_NUMBER=$(echo "$ARGUMENTS" | awk '{print $1}') +USERNAME=$(echo "$ARGUMENTS" | awk '{print $2}') + +# Validate issue number +if [ -z "$ISSUE_NUMBER" ]; then + echo "Usage: /mgw:assign [username]" + echo "" + echo " mgw:assign 42 — assign #42 to yourself" + echo " mgw:assign 42 alice — assign #42 to @alice" + exit 1 +fi + +if ! echo "$ISSUE_NUMBER" | grep -qE '^[0-9]+$'; then + echo "ERROR: Issue number must be numeric. Got: '${ISSUE_NUMBER}'" + exit 1 +fi +``` + + + +**Initialize .mgw/ and load existing state (from state.md):** + +```bash +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -z "$REPO_ROOT" ]; then + echo "ERROR: Not a git repository." + exit 1 +fi + +MGW_DIR="${REPO_ROOT}/.mgw" + +# Ensure directory structure +mkdir -p "${MGW_DIR}/active" "${MGW_DIR}/completed" + +# Ensure gitignore entries +for ENTRY in ".mgw/" ".worktrees/"; do + if ! grep -qF "${ENTRY}" "${REPO_ROOT}/.gitignore" 2>/dev/null; then + echo "${ENTRY}" >> "${REPO_ROOT}/.gitignore" + fi +done + +# Initialize cross-refs if missing +if [ ! -f "${MGW_DIR}/cross-refs.json" ]; then + echo '{"links":[]}' > "${MGW_DIR}/cross-refs.json" +fi + +# Find state file for this issue +STATE_FILE=$(ls "${MGW_DIR}/active/${ISSUE_NUMBER}-"*.json 2>/dev/null | head -1) +STATE_EXISTS=$( [ -n "$STATE_FILE" ] && echo "true" || echo "false" ) +``` + + + +**Resolve the assignee username:** + +```bash +# If no username provided, use the authenticated user +if [ -z "$USERNAME" ]; then + RESOLVED_USER=$(gh api user -q .login 2>/dev/null) + if [ -z "$RESOLVED_USER" ]; then + echo "ERROR: Cannot resolve current GitHub user. Check your gh auth status." + exit 1 + fi +else + RESOLVED_USER="$USERNAME" + # Validate user exists on GitHub + USER_EXISTS=$(gh api "users/${RESOLVED_USER}" -q .login 2>/dev/null) + if [ -z "$USER_EXISTS" ]; then + echo "ERROR: GitHub user '${RESOLVED_USER}' not found." + exit 1 + fi +fi + +echo "MGW: Assigning #${ISSUE_NUMBER} to @${RESOLVED_USER}..." +``` + + + +**Fetch issue metadata from GitHub:** + +```bash +ISSUE_DATA=$(gh issue view "$ISSUE_NUMBER" --json number,title,url,labels,assignees,state 2>/dev/null) +if [ -z "$ISSUE_DATA" ]; then + echo "ERROR: Issue #${ISSUE_NUMBER} not found in this repo." + exit 1 +fi + +ISSUE_TITLE=$(echo "$ISSUE_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin)['title'])" 2>/dev/null) +ISSUE_URL=$(echo "$ISSUE_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin)['url'])" 2>/dev/null) +ISSUE_STATE=$(echo "$ISSUE_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin)['state'])" 2>/dev/null) + +# Check if user is already assigned (idempotent) +ALREADY_ASSIGNED=$(echo "$ISSUE_DATA" | python3 -c " +import json,sys +d = json.load(sys.stdin) +assignees = [a['login'] for a in d.get('assignees', [])] +print('true' if '${RESOLVED_USER}' in assignees else 'false') +" 2>/dev/null) +``` + + + +**Assign the issue on GitHub:** + +```bash +if [ "$ALREADY_ASSIGNED" = "true" ]; then + echo "MGW: @${RESOLVED_USER} is already assigned to #${ISSUE_NUMBER} — confirming state." +else + if ! gh issue edit "$ISSUE_NUMBER" --add-assignee "$RESOLVED_USER" 2>/dev/null; then + echo "ERROR: Failed to assign @${RESOLVED_USER} to #${ISSUE_NUMBER}." + echo " Check that the user has access to this repo." + exit 1 + fi + echo "MGW: Assigned @${RESOLVED_USER} to #${ISSUE_NUMBER}." +fi +``` + + + +**Write assignee to .mgw/active/ state (create minimal entry if needed):** + +```bash +TIMESTAMP=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs current-timestamp --raw 2>/dev/null \ + || date -u +"%Y-%m-%dT%H:%M:%S.000Z") + +if [ "$STATE_EXISTS" = "true" ]; then + # Update existing state file: set issue.assignee field + python3 -c " +import json +with open('${STATE_FILE}') as f: + state = json.load(f) +state['issue']['assignee'] = '${RESOLVED_USER}' +state['updated_at'] = '${TIMESTAMP}' +with open('${STATE_FILE}', 'w') as f: + json.dump(state, f, indent=2) +print('updated') +" 2>/dev/null + +else + # No state file — generate slug and create minimal entry + SLUG=\$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs generate-slug "${ISSUE_TITLE}" --raw 2>/dev/null | cut -c1-40 \ + || echo "issue-${ISSUE_NUMBER}") + + NEW_STATE_FILE="${MGW_DIR}/active/${ISSUE_NUMBER}-${SLUG}.json" + + python3 -c " +import json +state = { + 'issue': { + 'number': ${ISSUE_NUMBER}, + 'title': '${ISSUE_TITLE}', + 'url': '${ISSUE_URL}', + 'labels': [], + 'assignee': '${RESOLVED_USER}' + }, + 'triage': { + 'scope': { 'size': 'unknown', 'file_count': 0, 'files': [], 'systems': [] }, + 'validity': 'pending', + 'security_risk': 'unknown', + 'security_notes': '', + 'conflicts': [], + 'last_comment_count': 0, + 'last_comment_at': None, + 'gate_result': { 'status': 'pending', 'blockers': [], 'warnings': [], 'missing_fields': [] } + }, + 'gsd_route': None, + 'gsd_artifacts': { 'type': None, 'path': None }, + 'pipeline_stage': 'new', + 'comments_posted': [], + 'linked_pr': None, + 'linked_issues': [], + 'linked_branches': [], + 'created_at': '${TIMESTAMP}', + 'updated_at': '${TIMESTAMP}' +} +with open('${NEW_STATE_FILE}', 'w') as f: + json.dump(state, f, indent=2) +print('created') +" 2>/dev/null + + STATE_FILE="$NEW_STATE_FILE" + echo "MGW: Created minimal state entry at ${STATE_FILE}" +fi +``` + + + +**Check if board is configured and emit board URL:** + +GitHub Projects v2 automatically syncs issue assignees to board items. No direct +GraphQL mutation is needed — the board will reflect the new assignee when refreshed. + +```bash +BOARD_URL=$(python3 -c " +import json, sys, os +try: + p = json.load(open('${MGW_DIR}/project.json')) + board = p.get('project', {}).get('project_board', {}) + print(board.get('url', '')) +except: + print('') +" 2>/dev/null || echo "") + +BOARD_ITEM_ID=$(python3 -c " +import json, sys +try: + p = json.load(open('${MGW_DIR}/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 "") + +BOARD_CONFIGURED=$( [ -n "$BOARD_URL" ] && echo "true" || echo "false" ) +``` + + + +**Emit assignment confirmation:** + +```bash +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " MGW ► ISSUE ASSIGNED" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Issue : #${ISSUE_NUMBER} — ${ISSUE_TITLE}" +echo " URL : ${ISSUE_URL}" +echo " Assignee: @${RESOLVED_USER}" +echo " State : ${ISSUE_STATE}" +if [ "$BOARD_CONFIGURED" = "true" ]; then + echo " Board : ${BOARD_URL}" + if [ -n "$BOARD_ITEM_ID" ]; then + echo " (board item updated automatically by GitHub)" + else + echo " (issue not yet added to board — run /mgw:board show)" + fi +fi +echo "" +if [ "$ALREADY_ASSIGNED" = "true" ]; then + echo " Note: @${RESOLVED_USER} was already the assignee — state confirmed." +fi +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +``` + + +