@snipcodeit/mgw 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ ---
2
+ name: board:show
3
+ description: Display board state, field IDs, item counts grouped by pipeline stage, and configured views
4
+ ---
5
+
6
+ <step name="subcommand_show">
7
+ **Execute 'show' subcommand:**
8
+
9
+ Only run if `$SUBCOMMAND = "show"`.
10
+
11
+ ```bash
12
+ if [ "$SUBCOMMAND" = "show" ]; then
13
+ if [ "$BOARD_CONFIGURED" = "false" ]; then
14
+ echo "No board configured. Run /mgw:board create first."
15
+ exit 1
16
+ fi
17
+
18
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
19
+ echo " MGW ► BOARD STATE: ${PROJECT_NAME}"
20
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
21
+ echo ""
22
+ echo "Board: #${BOARD_NUMBER} — ${BOARD_URL}"
23
+ echo "Node ID: ${BOARD_NODE_ID}"
24
+ echo ""
25
+ echo "Custom Fields:"
26
+ echo "$FIELDS_JSON" | python3 -c "
27
+ import json,sys
28
+ fields = json.load(sys.stdin)
29
+ for name, data in fields.items():
30
+ fid = data.get('field_id', 'unknown')
31
+ ftype = data.get('type', 'unknown')
32
+ fname = data.get('field_name', name)
33
+ if ftype == 'SINGLE_SELECT':
34
+ opts = len(data.get('options', {}))
35
+ print(f' {fname:<20} {fid} ({ftype}, {opts} options)')
36
+ else:
37
+ print(f' {fname:<20} {fid} ({ftype})')
38
+ " 2>/dev/null
39
+ echo ""
40
+ ```
41
+
42
+ **Fetch board items from GitHub to show current state:**
43
+
44
+ ```bash
45
+ echo "Fetching board items from GitHub..."
46
+
47
+ ITEMS_RESULT=$(gh api graphql -f query='
48
+ query($owner: String!, $number: Int!) {
49
+ user(login: $owner) {
50
+ projectV2(number: $number) {
51
+ title
52
+ items(first: 50) {
53
+ totalCount
54
+ nodes {
55
+ id
56
+ content {
57
+ ... on Issue {
58
+ number
59
+ title
60
+ state
61
+ }
62
+ ... on PullRequest {
63
+ number
64
+ title
65
+ state
66
+ }
67
+ }
68
+ fieldValues(first: 10) {
69
+ nodes {
70
+ ... on ProjectV2ItemFieldSingleSelectValue {
71
+ name
72
+ field { ... on ProjectV2SingleSelectField { name } }
73
+ }
74
+ ... on ProjectV2ItemFieldTextValue {
75
+ text
76
+ field { ... on ProjectV2Field { name } }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null)
86
+
87
+ # Fall back to org query if user query fails
88
+ if echo "$ITEMS_RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); _ = d['data']['user']['projectV2']" 2>/dev/null; then
89
+ ITEM_NODES=$(echo "$ITEMS_RESULT" | python3 -c "
90
+ import json,sys
91
+ d = json.load(sys.stdin)
92
+ print(json.dumps(d['data']['user']['projectV2']['items']['nodes']))
93
+ " 2>/dev/null || echo "[]")
94
+ TOTAL_ITEMS=$(echo "$ITEMS_RESULT" | python3 -c "
95
+ import json,sys
96
+ d = json.load(sys.stdin)
97
+ print(d['data']['user']['projectV2']['items']['totalCount'])
98
+ " 2>/dev/null || echo "0")
99
+ else
100
+ # Try organization lookup
101
+ ITEMS_RESULT=$(gh api graphql -f query='
102
+ query($owner: String!, $number: Int!) {
103
+ organization(login: $owner) {
104
+ projectV2(number: $number) {
105
+ title
106
+ items(first: 50) {
107
+ totalCount
108
+ nodes {
109
+ id
110
+ content {
111
+ ... on Issue { number title state }
112
+ ... on PullRequest { number title state }
113
+ }
114
+ fieldValues(first: 10) {
115
+ nodes {
116
+ ... on ProjectV2ItemFieldSingleSelectValue {
117
+ name
118
+ field { ... on ProjectV2SingleSelectField { name } }
119
+ }
120
+ ... on ProjectV2ItemFieldTextValue {
121
+ text
122
+ field { ... on ProjectV2Field { name } }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ ' -f owner="$OWNER" -F number="$BOARD_NUMBER" 2>/dev/null)
132
+
133
+ ITEM_NODES=$(echo "$ITEMS_RESULT" | python3 -c "
134
+ import json,sys
135
+ d = json.load(sys.stdin)
136
+ org_data = d.get('data', {}).get('organization') or d.get('data', {}).get('user', {})
137
+ proj = org_data.get('projectV2', {})
138
+ print(json.dumps(proj.get('items', {}).get('nodes', [])))
139
+ " 2>/dev/null || echo "[]")
140
+ TOTAL_ITEMS=$(echo "$ITEMS_RESULT" | python3 -c "
141
+ import json,sys
142
+ d = json.load(sys.stdin)
143
+ org_data = d.get('data', {}).get('organization') or d.get('data', {}).get('user', {})
144
+ proj = org_data.get('projectV2', {})
145
+ print(proj.get('items', {}).get('totalCount', 0))
146
+ " 2>/dev/null || echo "0")
147
+ fi
148
+
149
+ echo "Board Items (${TOTAL_ITEMS} total):"
150
+ echo ""
151
+
152
+ echo "$ITEM_NODES" | python3 -c "
153
+ import json,sys
154
+ nodes = json.load(sys.stdin)
155
+
156
+ if not nodes:
157
+ print(' No items on board yet.')
158
+ print(' Run /mgw:run 73 to sync issues as board items (#73).')
159
+ sys.exit(0)
160
+
161
+ # Group by Status field
162
+ by_status = {}
163
+ for node in nodes:
164
+ content = node.get('content', {})
165
+ num = content.get('number', '?')
166
+ title = content.get('title', 'Unknown')[:45]
167
+ status = 'No Status'
168
+ for fv in node.get('fieldValues', {}).get('nodes', []):
169
+ field = fv.get('field', {})
170
+ if field.get('name') == 'Status':
171
+ status = fv.get('name', 'No Status')
172
+ break
173
+ by_status.setdefault(status, []).append((num, title))
174
+
175
+ order = ['Executing', 'Planning', 'Verifying', 'PR Created', 'Triaged', 'Approved',
176
+ 'Discussing', 'New', 'Needs Info', 'Needs Security Review', 'Blocked', 'Failed', 'Done', 'No Status']
177
+
178
+ for status in order:
179
+ items = by_status.pop(status, [])
180
+ if items:
181
+ print(f' {status} ({len(items)}):')
182
+ for num, title in items:
183
+ print(f' #{num} {title}')
184
+
185
+ for status, items in by_status.items():
186
+ print(f' {status} ({len(items)}):')
187
+ for num, title in items:
188
+ print(f' #{num} {title}')
189
+ " 2>/dev/null
190
+
191
+ echo ""
192
+
193
+ # Show configured views if any
194
+ VIEWS_JSON=$(echo "$PROJECT_JSON" | python3 -c "
195
+ import json,sys
196
+ p = json.load(sys.stdin)
197
+ board = p.get('project', {}).get('project_board', {})
198
+ views = board.get('views', {})
199
+ print(json.dumps(views))
200
+ " 2>/dev/null || echo "{}")
201
+
202
+ if [ "$VIEWS_JSON" != "{}" ] && [ -n "$VIEWS_JSON" ]; then
203
+ echo "Configured Views:"
204
+ echo "$VIEWS_JSON" | python3 -c "
205
+ import json,sys
206
+ views = json.load(sys.stdin)
207
+ for key, v in views.items():
208
+ print(f' {v[\"name\"]:<40} {v[\"layout\"]:<16} (ID: {v[\"view_id\"]})')
209
+ " 2>/dev/null
210
+ echo ""
211
+ else
212
+ echo "Views: none configured"
213
+ echo " Run /mgw:board views kanban to create the kanban view"
214
+ echo ""
215
+ fi
216
+
217
+ echo "Open board: ${BOARD_URL}"
218
+
219
+ fi # end show subcommand
220
+ ```
221
+ </step>
@@ -0,0 +1,461 @@
1
+ ---
2
+ name: board:sync
3
+ description: Reconcile all board items with current .mgw/active/ state
4
+ ---
5
+
6
+ <step name="subcommand_sync">
7
+ **Execute 'sync' subcommand:**
8
+
9
+ Only run if `$SUBCOMMAND = "sync"`.
10
+
11
+ Reconcile all `.mgw/active/*.json` state files with their GitHub Projects v2 board items.
12
+ Adds missing issues to the board, then updates Status, AI Agent State, Phase, and
13
+ Milestone fields to match current local state. Prints a reconciliation diff table.
14
+
15
+ ```bash
16
+ if [ "$SUBCOMMAND" = "sync" ]; then
17
+ if [ "$BOARD_CONFIGURED" = "false" ]; then
18
+ echo "No board configured. Run /mgw:board create first."
19
+ exit 1
20
+ fi
21
+
22
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
23
+ echo " MGW ► BOARD SYNC: ${PROJECT_NAME}"
24
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
25
+ echo ""
26
+ ```
27
+
28
+ **Collect active state files:**
29
+
30
+ ```bash
31
+ ACTIVE_DIR="${MGW_DIR}/active"
32
+
33
+ if ! ls "${ACTIVE_DIR}"/*.json 1>/dev/null 2>&1; then
34
+ echo "No active issues found in ${ACTIVE_DIR}/"
35
+ echo "Nothing to sync."
36
+ exit 0
37
+ fi
38
+
39
+ ACTIVE_FILES=$(ls "${ACTIVE_DIR}"/*.json 2>/dev/null)
40
+ ACTIVE_COUNT=$(echo "$ACTIVE_FILES" | wc -l)
41
+
42
+ echo "Reconciling ${ACTIVE_COUNT} active issues against board..."
43
+ echo ""
44
+ ```
45
+
46
+ **Read field IDs from project.json:**
47
+
48
+ ```bash
49
+ STATUS_FIELD_ID=$(echo "$FIELDS_JSON" | python3 -c "
50
+ import json,sys
51
+ fields = json.load(sys.stdin)
52
+ print(fields.get('status', {}).get('field_id', ''))
53
+ " 2>/dev/null)
54
+
55
+ AI_STATE_FIELD_ID=$(echo "$FIELDS_JSON" | python3 -c "
56
+ import json,sys
57
+ fields = json.load(sys.stdin)
58
+ print(fields.get('ai_agent_state', {}).get('field_id', ''))
59
+ " 2>/dev/null)
60
+
61
+ PHASE_FIELD_ID=$(echo "$FIELDS_JSON" | python3 -c "
62
+ import json,sys
63
+ fields = json.load(sys.stdin)
64
+ print(fields.get('phase', {}).get('field_id', ''))
65
+ " 2>/dev/null)
66
+
67
+ MILESTONE_FIELD_ID=$(echo "$FIELDS_JSON" | python3 -c "
68
+ import json,sys
69
+ fields = json.load(sys.stdin)
70
+ print(fields.get('milestone', {}).get('field_id', ''))
71
+ " 2>/dev/null)
72
+
73
+ STATUS_OPTIONS=$(echo "$FIELDS_JSON" | python3 -c "
74
+ import json,sys
75
+ fields = json.load(sys.stdin)
76
+ print(json.dumps(fields.get('status', {}).get('options', {})))
77
+ " 2>/dev/null || echo "{}")
78
+ ```
79
+
80
+ **Fetch all current board items in a single GraphQL call:**
81
+
82
+ ```bash
83
+ echo "Fetching current board items from GitHub..."
84
+
85
+ BOARD_ITEMS_RESULT=$(gh api graphql -f query='
86
+ query($projectId: ID!) {
87
+ node(id: $projectId) {
88
+ ... on ProjectV2 {
89
+ items(first: 100) {
90
+ nodes {
91
+ id
92
+ content {
93
+ ... on Issue {
94
+ number
95
+ id
96
+ }
97
+ ... on PullRequest {
98
+ number
99
+ id
100
+ }
101
+ }
102
+ fieldValues(first: 10) {
103
+ nodes {
104
+ ... on ProjectV2ItemFieldSingleSelectValue {
105
+ name
106
+ field { ... on ProjectV2SingleSelectField { name } }
107
+ }
108
+ ... on ProjectV2ItemFieldTextValue {
109
+ text
110
+ field { ... on ProjectV2Field { name } }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ ' -f projectId="$BOARD_NODE_ID" 2>/dev/null)
120
+
121
+ # Build: issue_number -> {item_id, current_status, current_ai_state, current_phase, current_milestone}
122
+ BOARD_ITEM_MAP=$(echo "$BOARD_ITEMS_RESULT" | python3 -c "
123
+ import json,sys
124
+ d = json.load(sys.stdin)
125
+ nodes = d.get('data', {}).get('node', {}).get('items', {}).get('nodes', [])
126
+ result = {}
127
+ for node in nodes:
128
+ content = node.get('content', {})
129
+ if not content:
130
+ continue
131
+ num = content.get('number')
132
+ if num is None:
133
+ continue
134
+ item_id = node.get('id', '')
135
+ status = ''
136
+ ai_state = ''
137
+ phase = ''
138
+ milestone = ''
139
+ for fv in node.get('fieldValues', {}).get('nodes', []):
140
+ fname = fv.get('field', {}).get('name', '')
141
+ if fname == 'Status':
142
+ status = fv.get('name', '')
143
+ elif fname == 'AI Agent State':
144
+ ai_state = fv.get('text', '')
145
+ elif fname == 'Phase':
146
+ phase = fv.get('text', '')
147
+ elif fname == 'Milestone':
148
+ milestone = fv.get('text', '')
149
+ result[str(num)] = {
150
+ 'item_id': item_id,
151
+ 'status': status,
152
+ 'ai_agent_state': ai_state,
153
+ 'phase': phase,
154
+ 'milestone': milestone
155
+ }
156
+ print(json.dumps(result))
157
+ " 2>/dev/null || echo "{}")
158
+
159
+ if [ "$BOARD_ITEM_MAP" = "{}" ] && [ -n "$BOARD_ITEMS_RESULT" ]; then
160
+ echo "WARNING: Could not parse board items. Continuing with empty map."
161
+ fi
162
+ ```
163
+
164
+ **Reconcile each active state file:**
165
+
166
+ ```bash
167
+ SYNC_RESULTS=()
168
+ UPDATED_COUNT=0
169
+ ADDED_COUNT=0
170
+ ERROR_COUNT=0
171
+
172
+ for STATE_FILE in $ACTIVE_FILES; do
173
+ # Parse state file
174
+ ISSUE_DATA=$(python3 -c "
175
+ import json,sys
176
+ try:
177
+ s = json.load(open('${STATE_FILE}'))
178
+ num = str(s.get('issue', {}).get('number', ''))
179
+ title = s.get('issue', {}).get('title', 'Unknown')[:45]
180
+ stage = s.get('pipeline_stage', 'new')
181
+ route = s.get('gsd_route', '') or ''
182
+ labels = s.get('issue', {}).get('labels', [])
183
+ # Extract phase from labels matching 'phase:*'
184
+ phase_val = ''
185
+ for lbl in labels:
186
+ if isinstance(lbl, str) and lbl.startswith('phase:'):
187
+ phase_val = lbl.replace('phase:', '')
188
+ break
189
+ elif isinstance(lbl, dict) and lbl.get('name', '').startswith('phase:'):
190
+ phase_val = lbl['name'].replace('phase:', '')
191
+ break
192
+ print(json.dumps({'number': num, 'title': title, 'stage': stage, 'route': route, 'phase': phase_val}))
193
+ except Exception as e:
194
+ print(json.dumps({'error': str(e)}))
195
+ " 2>/dev/null || echo '{"error":"parse failed"}')
196
+
197
+ ISSUE_NUMBER=$(echo "$ISSUE_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" 2>/dev/null)
198
+ ISSUE_TITLE=$(echo "$ISSUE_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('title','Unknown'))" 2>/dev/null)
199
+ PIPELINE_STAGE=$(echo "$ISSUE_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('stage','new'))" 2>/dev/null)
200
+ PHASE_VALUE=$(echo "$ISSUE_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('phase',''))" 2>/dev/null)
201
+
202
+ if [ -z "$ISSUE_NUMBER" ]; then
203
+ SYNC_RESULTS+=("| ? | (parse error: ${STATE_FILE##*/}) | — | ERROR: could not read state |")
204
+ ERROR_COUNT=$((ERROR_COUNT + 1))
205
+ continue
206
+ fi
207
+
208
+ # Look up board item
209
+ ITEM_DATA=$(echo "$BOARD_ITEM_MAP" | python3 -c "
210
+ import json,sys
211
+ m = json.load(sys.stdin)
212
+ d = m.get('${ISSUE_NUMBER}', {})
213
+ print(json.dumps(d))
214
+ " 2>/dev/null || echo "{}")
215
+
216
+ BOARD_ITEM_ID=$(echo "$ITEM_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('item_id',''))" 2>/dev/null)
217
+ CURRENT_STATUS=$(echo "$ITEM_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status',''))" 2>/dev/null)
218
+ CURRENT_PHASE=$(echo "$ITEM_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('phase',''))" 2>/dev/null)
219
+ CURRENT_MILESTONE=$(echo "$ITEM_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('milestone',''))" 2>/dev/null)
220
+
221
+ CHANGED_FIELDS=""
222
+
223
+ # If issue is not on board, add it
224
+ if [ -z "$BOARD_ITEM_ID" ]; then
225
+ ISSUE_NODE_ID=$(gh issue view "$ISSUE_NUMBER" --json id -q .id 2>/dev/null || echo "")
226
+ if [ -n "$ISSUE_NODE_ID" ]; then
227
+ ADD_RESULT=$(gh api graphql -f query='
228
+ mutation($projectId: ID!, $contentId: ID!) {
229
+ addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
230
+ item { id }
231
+ }
232
+ }
233
+ ' -f projectId="$BOARD_NODE_ID" -f contentId="$ISSUE_NODE_ID" \
234
+ --jq '.data.addProjectV2ItemById.item.id' 2>/dev/null || echo "")
235
+
236
+ if [ -n "$ADD_RESULT" ]; then
237
+ BOARD_ITEM_ID="$ADD_RESULT"
238
+ CHANGED_FIELDS="added to board"
239
+ ADDED_COUNT=$((ADDED_COUNT + 1))
240
+ else
241
+ SYNC_RESULTS+=("| #${ISSUE_NUMBER} | ${ISSUE_TITLE} | ${PIPELINE_STAGE} | ERROR: could not add to board |")
242
+ ERROR_COUNT=$((ERROR_COUNT + 1))
243
+ continue
244
+ fi
245
+ else
246
+ SYNC_RESULTS+=("| #${ISSUE_NUMBER} | ${ISSUE_TITLE} | ${PIPELINE_STAGE} | ERROR: could not resolve issue node ID |")
247
+ ERROR_COUNT=$((ERROR_COUNT + 1))
248
+ continue
249
+ fi
250
+ fi
251
+
252
+ # Get milestone title from project.json for this issue's milestone
253
+ MILESTONE_VALUE=$(python3 -c "
254
+ import json,sys
255
+ try:
256
+ p = json.load(open('${MGW_DIR}/project.json'))
257
+ current_ms = p.get('current_milestone', 1)
258
+ for i, m in enumerate(p.get('milestones', []), 1):
259
+ for issue in m.get('issues', []):
260
+ if str(issue.get('github_number', '')) == '${ISSUE_NUMBER}':
261
+ print(m.get('title', ''))
262
+ sys.exit(0)
263
+ print('')
264
+ except:
265
+ print('')
266
+ " 2>/dev/null)
267
+
268
+ # Update Status field if it differs
269
+ if [ -n "$STATUS_FIELD_ID" ]; then
270
+ DESIRED_OPTION_ID=$(echo "$STATUS_OPTIONS" | python3 -c "
271
+ import json,sys
272
+ opts = json.load(sys.stdin)
273
+ print(opts.get('${PIPELINE_STAGE}', ''))
274
+ " 2>/dev/null)
275
+
276
+ if [ -n "$DESIRED_OPTION_ID" ]; then
277
+ # Map current board status name back to stage for comparison
278
+ CURRENT_STAGE=$(echo "$CURRENT_STATUS" | python3 -c "
279
+ import sys
280
+ stage_map = {
281
+ 'New': 'new', 'Triaged': 'triaged', 'Needs Info': 'needs-info',
282
+ 'Needs Security Review': 'needs-security-review', 'Discussing': 'discussing',
283
+ 'Approved': 'approved', 'Planning': 'planning', 'Executing': 'executing',
284
+ 'Verifying': 'verifying', 'PR Created': 'pr-created', 'Done': 'done',
285
+ 'Failed': 'failed', 'Blocked': 'blocked'
286
+ }
287
+ label = sys.stdin.read().strip()
288
+ print(stage_map.get(label, ''))
289
+ " 2>/dev/null)
290
+
291
+ if [ "$CURRENT_STAGE" != "$PIPELINE_STAGE" ]; then
292
+ gh api graphql -f query='
293
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
294
+ updateProjectV2ItemFieldValue(input: {
295
+ projectId: $projectId
296
+ itemId: $itemId
297
+ fieldId: $fieldId
298
+ value: { singleSelectOptionId: $optionId }
299
+ }) { projectV2Item { id } }
300
+ }
301
+ ' -f projectId="$BOARD_NODE_ID" \
302
+ -f itemId="$BOARD_ITEM_ID" \
303
+ -f fieldId="$STATUS_FIELD_ID" \
304
+ -f optionId="$DESIRED_OPTION_ID" 2>/dev/null || true
305
+
306
+ if [ -n "$CHANGED_FIELDS" ]; then
307
+ CHANGED_FIELDS="${CHANGED_FIELDS}, Status (${CURRENT_STATUS:-none}→${PIPELINE_STAGE})"
308
+ else
309
+ CHANGED_FIELDS="Status (${CURRENT_STATUS:-none}→${PIPELINE_STAGE})"
310
+ fi
311
+ UPDATED_COUNT=$((UPDATED_COUNT + 1))
312
+ fi
313
+ fi
314
+ fi
315
+
316
+ # Update AI Agent State field — sync always clears it (ephemeral during execution)
317
+ if [ -n "$AI_STATE_FIELD_ID" ] && [ -n "$CURRENT_AI_STATE" ] && [ "$CURRENT_AI_STATE" != "" ]; then
318
+ CURRENT_AI_STATE=$(echo "$ITEM_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin).get('ai_agent_state',''))" 2>/dev/null)
319
+ if [ -n "$CURRENT_AI_STATE" ]; then
320
+ gh api graphql -f query='
321
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
322
+ updateProjectV2ItemFieldValue(input: {
323
+ projectId: $projectId
324
+ itemId: $itemId
325
+ fieldId: $fieldId
326
+ value: { text: $text }
327
+ }) { projectV2Item { id } }
328
+ }
329
+ ' -f projectId="$BOARD_NODE_ID" \
330
+ -f itemId="$BOARD_ITEM_ID" \
331
+ -f fieldId="$AI_STATE_FIELD_ID" \
332
+ -f text="" 2>/dev/null || true
333
+
334
+ if [ -n "$CHANGED_FIELDS" ]; then
335
+ CHANGED_FIELDS="${CHANGED_FIELDS}, AI Agent State (cleared)"
336
+ else
337
+ CHANGED_FIELDS="AI Agent State (cleared)"
338
+ fi
339
+ fi
340
+ fi
341
+
342
+ # Update Phase field if it differs
343
+ if [ -n "$PHASE_FIELD_ID" ] && [ -n "$PHASE_VALUE" ] && [ "$PHASE_VALUE" != "$CURRENT_PHASE" ]; then
344
+ gh api graphql -f query='
345
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
346
+ updateProjectV2ItemFieldValue(input: {
347
+ projectId: $projectId
348
+ itemId: $itemId
349
+ fieldId: $fieldId
350
+ value: { text: $text }
351
+ }) { projectV2Item { id } }
352
+ }
353
+ ' -f projectId="$BOARD_NODE_ID" \
354
+ -f itemId="$BOARD_ITEM_ID" \
355
+ -f fieldId="$PHASE_FIELD_ID" \
356
+ -f text="$PHASE_VALUE" 2>/dev/null || true
357
+
358
+ if [ -n "$CHANGED_FIELDS" ]; then
359
+ CHANGED_FIELDS="${CHANGED_FIELDS}, Phase (${CURRENT_PHASE:-none}→${PHASE_VALUE})"
360
+ else
361
+ CHANGED_FIELDS="Phase (${CURRENT_PHASE:-none}→${PHASE_VALUE})"
362
+ fi
363
+ fi
364
+
365
+ # Update Milestone field if it differs
366
+ if [ -n "$MILESTONE_FIELD_ID" ] && [ -n "$MILESTONE_VALUE" ] && [ "$MILESTONE_VALUE" != "$CURRENT_MILESTONE" ]; then
367
+ gh api graphql -f query='
368
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
369
+ updateProjectV2ItemFieldValue(input: {
370
+ projectId: $projectId
371
+ itemId: $itemId
372
+ fieldId: $fieldId
373
+ value: { text: $text }
374
+ }) { projectV2Item { id } }
375
+ }
376
+ ' -f projectId="$BOARD_NODE_ID" \
377
+ -f itemId="$BOARD_ITEM_ID" \
378
+ -f fieldId="$MILESTONE_FIELD_ID" \
379
+ -f text="$MILESTONE_VALUE" 2>/dev/null || true
380
+
381
+ if [ -n "$CHANGED_FIELDS" ]; then
382
+ CHANGED_FIELDS="${CHANGED_FIELDS}, Milestone (${CURRENT_MILESTONE:-none}→${MILESTONE_VALUE})"
383
+ else
384
+ CHANGED_FIELDS="Milestone (${CURRENT_MILESTONE:-none}→${MILESTONE_VALUE})"
385
+ fi
386
+ fi
387
+
388
+ # Update Plan Summary field from latest plan comment (if field exists)
389
+ PLAN_SUMMARY_FIELD_ID=$(echo "$FIELDS_JSON" | python3 -c "
390
+ import json,sys
391
+ fields = json.load(sys.stdin)
392
+ print(fields.get('plan_summary', {}).get('field_id', ''))
393
+ " 2>/dev/null)
394
+
395
+ if [ -n "$PLAN_SUMMARY_FIELD_ID" ] && [ -n "$BOARD_ITEM_ID" ]; then
396
+ PLAN_SUMMARY=$(node -e "
397
+ const ic = require('./lib/issue-context.cjs');
398
+ ic.findLatestComment(${ISSUE_NUMBER}, 'plan')
399
+ .then(plan => {
400
+ if (plan) {
401
+ const body = plan.body.replace(/<!--[\\s\\S]*?-->\\n?/, '').trim();
402
+ const summary = body.split('\\n').slice(0, 3).join(' ').substring(0, 200);
403
+ process.stdout.write(summary);
404
+ }
405
+ })
406
+ .catch(() => {});
407
+ " 2>/dev/null || echo "")
408
+
409
+ if [ -n "$PLAN_SUMMARY" ]; then
410
+ gh api graphql -f query='
411
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) {
412
+ updateProjectV2ItemFieldValue(input: {
413
+ projectId: $projectId
414
+ itemId: $itemId
415
+ fieldId: $fieldId
416
+ value: { text: $text }
417
+ }) { projectV2Item { id } }
418
+ }
419
+ ' -f projectId="$BOARD_NODE_ID" \
420
+ -f itemId="$BOARD_ITEM_ID" \
421
+ -f fieldId="$PLAN_SUMMARY_FIELD_ID" \
422
+ -f text="$PLAN_SUMMARY" 2>/dev/null || true
423
+
424
+ if [ -n "$CHANGED_FIELDS" ]; then
425
+ CHANGED_FIELDS="${CHANGED_FIELDS}, Plan Summary (updated)"
426
+ else
427
+ CHANGED_FIELDS="Plan Summary (updated)"
428
+ fi
429
+ fi
430
+ fi
431
+
432
+ if [ -z "$CHANGED_FIELDS" ]; then
433
+ CHANGED_FIELDS="no changes"
434
+ fi
435
+
436
+ SYNC_RESULTS+=("| #${ISSUE_NUMBER} | ${ISSUE_TITLE} | ${PIPELINE_STAGE} | ${CHANGED_FIELDS} |")
437
+ done
438
+ ```
439
+
440
+ **Print reconciliation diff table:**
441
+
442
+ ```bash
443
+ echo "| Issue | Title | Stage | Changes |"
444
+ echo "|-------|-------|-------|---------|"
445
+ for ROW in "${SYNC_RESULTS[@]}"; do
446
+ echo "$ROW"
447
+ done
448
+
449
+ echo ""
450
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
451
+ echo " Sync complete: ${ACTIVE_COUNT} checked, ${UPDATED_COUNT} updated, ${ADDED_COUNT} added, ${ERROR_COUNT} errors"
452
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
453
+
454
+ if [ "$ERROR_COUNT" -gt 0 ]; then
455
+ echo ""
456
+ echo "WARNING: ${ERROR_COUNT} issue(s) had errors. Check board manually: ${BOARD_URL}"
457
+ fi
458
+
459
+ fi # end sync subcommand
460
+ ```
461
+ </step>