@snipcodeit/mgw 0.1.1 → 0.1.2

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.
package/commands/sync.md CHANGED
@@ -16,6 +16,11 @@ if the GitHub issue is still open, if linked PRs were merged/closed, and if trac
16
16
  branches still exist. Moves completed items to .mgw/completed/, cleans up branches
17
17
  and lingering worktrees, flags inconsistencies.
18
18
 
19
+ Also pulls board state to reconstruct missing local state files — enabling multi-machine
20
+ workflows. On a fresh machine with no .mgw/active/ files, sync reads the GitHub Projects
21
+ v2 board's Status field and rebuilds local state for every in-progress issue, so work
22
+ can continue without re-triaging from scratch.
23
+
19
24
  Run periodically or when starting a new session to get a clean view.
20
25
  </objective>
21
26
 
@@ -28,6 +33,321 @@ Run periodically or when starting a new session to get a clean view.
28
33
 
29
34
  <process>
30
35
 
36
+ <step name="pull_board_state">
37
+ **Pull board state to reconstruct missing local .mgw/active/ files.**
38
+
39
+ This step runs first — before scan_active — so that any issues the board knows about
40
+ but this machine doesn't will be present by the time check_each runs. This is the
41
+ multi-machine sync mechanism: board is the distributed source of truth, .mgw/active/
42
+ is the local cache that sync rebuilds from it.
43
+
44
+ Two sub-operations, both non-blocking:
45
+ 1. **Board discovery** — if project_board.node_id is missing, find and register it.
46
+ 2. **Board pull** — fetch all board items, reconstruct .mgw/active/ for any issue not
47
+ present locally, detect stage drift for issues that exist on both sides.
48
+
49
+ ```bash
50
+ REPO_ROOT=$(git rev-parse --show-toplevel)
51
+ MGW_DIR="${REPO_ROOT}/.mgw"
52
+ OWNER=$(gh repo view --json owner -q .owner.login 2>/dev/null)
53
+ ACTIVE_DIR="${MGW_DIR}/active"
54
+ mkdir -p "$ACTIVE_DIR"
55
+
56
+ BOARD_NODE_ID=$(python3 -c "
57
+ import json
58
+ try:
59
+ p = json.load(open('${MGW_DIR}/project.json'))
60
+ print(p.get('project', {}).get('project_board', {}).get('node_id', ''))
61
+ except: print('')
62
+ " 2>/dev/null || echo "")
63
+
64
+ BOARD_DISCOVERED=""
65
+ BOARD_PULL_CREATED=0
66
+ BOARD_PULL_DRIFT="[]"
67
+ BOARD_PULL_ERRORS="[]"
68
+
69
+ # Sub-operation 1: board discovery
70
+ if [ -z "$BOARD_NODE_ID" ] && [ -f "${MGW_DIR}/project.json" ]; then
71
+ PROJECT_NAME=$(python3 -c "
72
+ import json
73
+ try:
74
+ p = json.load(open('${MGW_DIR}/project.json'))
75
+ print(p.get('project', {}).get('name', ''))
76
+ except: print('')
77
+ " 2>/dev/null || echo "")
78
+
79
+ if [ -n "$PROJECT_NAME" ]; then
80
+ DISCOVERED=$(node -e "
81
+ const { findExistingBoard, getProjectFields } = require('./lib/github.cjs');
82
+ const board = findExistingBoard('${OWNER}', '${PROJECT_NAME}');
83
+ if (!board) { process.stdout.write(''); process.exit(0); }
84
+ const fields = getProjectFields('${OWNER}', board.number) || {};
85
+ console.log(JSON.stringify({ ...board, fields }));
86
+ " 2>/dev/null || echo "")
87
+
88
+ if [ -n "$DISCOVERED" ]; then
89
+ python3 -c "
90
+ import json
91
+ with open('${MGW_DIR}/project.json') as f:
92
+ project = json.load(f)
93
+ d = json.loads('${DISCOVERED}')
94
+ project['project']['project_board'] = {
95
+ 'number': d['number'], 'url': d['url'],
96
+ 'node_id': d['nodeId'], 'fields': d.get('fields', {})
97
+ }
98
+ with open('${MGW_DIR}/project.json', 'w') as f:
99
+ json.dump(project, f, indent=2)
100
+ " 2>/dev/null
101
+ BOARD_NODE_ID=$(echo "$DISCOVERED" | python3 -c "import json,sys; print(json.load(sys.stdin)['nodeId'])" 2>/dev/null)
102
+ DISC_NUMBER=$(echo "$DISCOVERED" | python3 -c "import json,sys; print(json.load(sys.stdin)['number'])" 2>/dev/null)
103
+ DISC_URL=$(echo "$DISCOVERED" | python3 -c "import json,sys; print(json.load(sys.stdin)['url'])" 2>/dev/null)
104
+ BOARD_DISCOVERED="#${DISC_NUMBER} — ${DISC_URL}"
105
+ fi
106
+ fi
107
+ fi
108
+
109
+ # Sub-operation 2: board pull
110
+ if [ -n "$BOARD_NODE_ID" ]; then
111
+ echo "Pulling board state from GitHub..."
112
+
113
+ BOARD_ITEMS=$(gh api graphql -f query='
114
+ query($projectId: ID!) {
115
+ node(id: $projectId) {
116
+ ... on ProjectV2 {
117
+ items(first: 100) {
118
+ nodes {
119
+ id
120
+ content {
121
+ ... on Issue { number title url }
122
+ }
123
+ fieldValues(first: 10) {
124
+ nodes {
125
+ ... on ProjectV2ItemFieldSingleSelectValue {
126
+ name
127
+ field { ... on ProjectV2SingleSelectField { name } }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ ' -f projectId="$BOARD_NODE_ID" \
137
+ --jq '.data.node.items.nodes' 2>/dev/null || echo "[]")
138
+
139
+ # One Python call handles: map board items, detect missing local files,
140
+ # reconstruct state from GitHub issue data, detect drift on existing files.
141
+ PULL_RESULT=$(echo "$BOARD_ITEMS" | ACTIVE_DIR="$ACTIVE_DIR" MGW_DIR="$MGW_DIR" python3 << 'PYEOF'
142
+ import json, sys, os, subprocess, re
143
+
144
+ ACTIVE_DIR = os.environ['ACTIVE_DIR']
145
+ GSD_TOOLS = os.path.expanduser('~/.claude/get-shit-done/bin/gsd-tools.cjs')
146
+
147
+ STATUS_TO_STAGE = {
148
+ 'New': 'new', 'Triaged': 'triaged',
149
+ 'Needs Info': 'needs-info', 'Needs Security Review': 'needs-security-review',
150
+ 'Discussing': 'discussing', 'Approved': 'approved',
151
+ 'Planning': 'planning', 'Executing': 'executing',
152
+ 'Verifying': 'verifying', 'PR Created': 'pr-created',
153
+ 'Done': 'done', 'Failed': 'failed', 'Blocked': 'blocked'
154
+ }
155
+
156
+ nodes = json.load(sys.stdin)
157
+ created = []
158
+ drift = []
159
+ errors = []
160
+
161
+ for node in nodes:
162
+ content = node.get('content', {})
163
+ num = content.get('number')
164
+ if num is None:
165
+ continue
166
+
167
+ status_label = ''
168
+ route_label = ''
169
+ for fv in node.get('fieldValues', {}).get('nodes', []):
170
+ fname = fv.get('field', {}).get('name', '')
171
+ if fname == 'Status':
172
+ status_label = fv.get('name', '')
173
+ elif fname == 'GSD Route':
174
+ route_label = fv.get('name', '')
175
+
176
+ board_stage = STATUS_TO_STAGE.get(status_label, 'new')
177
+
178
+ # Done items belong in completed/, not active/ — skip
179
+ if board_stage == 'done':
180
+ continue
181
+
182
+ # Check for existing local active file
183
+ existing = None
184
+ for fname in os.listdir(ACTIVE_DIR):
185
+ if fname.startswith(f'{num}-') and fname.endswith('.json'):
186
+ existing = os.path.join(ACTIVE_DIR, fname)
187
+ break
188
+
189
+ if existing is None:
190
+ # No local file — reconstruct from GitHub issue data
191
+ try:
192
+ r = subprocess.run(
193
+ ['gh', 'issue', 'view', str(num),
194
+ '--json', 'number,title,url,labels,assignees'],
195
+ capture_output=True, text=True
196
+ )
197
+ issue = json.loads(r.stdout)
198
+ except Exception as e:
199
+ errors.append({'number': num, 'error': str(e)})
200
+ continue
201
+
202
+ title = issue.get('title', content.get('title', ''))
203
+
204
+ try:
205
+ slug = subprocess.run(
206
+ ['node', GSD_TOOLS, 'generate-slug', title, '--raw'],
207
+ capture_output=True, text=True
208
+ ).stdout.strip()[:40]
209
+ except:
210
+ slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')[:40]
211
+
212
+ try:
213
+ ts = subprocess.run(
214
+ ['node', GSD_TOOLS, 'current-timestamp', '--raw'],
215
+ capture_output=True, text=True
216
+ ).stdout.strip()
217
+ except:
218
+ from datetime import datetime
219
+ ts = datetime.utcnow().isoformat() + 'Z'
220
+
221
+ labels = [l.get('name', '') if isinstance(l, dict) else str(l)
222
+ for l in issue.get('labels', [])]
223
+ assignees = issue.get('assignees', [])
224
+ assignee = assignees[0].get('login') if assignees else None
225
+
226
+ state = {
227
+ 'issue': {
228
+ 'number': num,
229
+ 'title': title,
230
+ 'url': issue.get('url', content.get('url', '')),
231
+ 'labels': labels,
232
+ 'assignee': assignee
233
+ },
234
+ 'triage': {
235
+ 'scope': {'files': 0, 'systems': []},
236
+ 'validity': 'confirmed',
237
+ 'security_notes': '',
238
+ 'conflicts': [],
239
+ 'last_comment_count': 0,
240
+ 'last_comment_at': None,
241
+ 'gate_result': {
242
+ 'status': 'passed',
243
+ 'blockers': [],
244
+ 'warnings': [f'Reconstructed from board state by mgw:sync — {ts}'],
245
+ 'missing_fields': []
246
+ }
247
+ },
248
+ 'gsd_route': route_label or None,
249
+ 'gsd_artifacts': {'type': None, 'path': None},
250
+ 'pipeline_stage': board_stage,
251
+ 'reconstructed_from_board': True,
252
+ 'comments_posted': [],
253
+ 'linked_pr': None,
254
+ 'linked_issues': [],
255
+ 'linked_branches': []
256
+ }
257
+
258
+ state_path = os.path.join(ACTIVE_DIR, f'{num}-{slug}.json')
259
+ with open(state_path, 'w') as f:
260
+ json.dump(state, f, indent=2)
261
+
262
+ created.append({'number': num, 'title': title, 'stage': board_stage})
263
+
264
+ else:
265
+ # Local file exists — detect stage drift
266
+ try:
267
+ local = json.load(open(existing))
268
+ local_stage = local.get('pipeline_stage', 'new')
269
+ if local_stage != board_stage:
270
+ drift.append({
271
+ 'number': num,
272
+ 'title': content.get('title', ''),
273
+ 'local': local_stage,
274
+ 'board': board_stage
275
+ })
276
+ except:
277
+ pass
278
+
279
+ print(json.dumps({'created': created, 'drift': drift, 'errors': errors}))
280
+ PYEOF
281
+ )
282
+
283
+ BOARD_PULL_CREATED=$(echo "$PULL_RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('created', [])))" 2>/dev/null || echo "0")
284
+ BOARD_PULL_DRIFT=$(echo "$PULL_RESULT" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin).get('drift', [])))" 2>/dev/null || echo "[]")
285
+ BOARD_PULL_ERRORS=$(echo "$PULL_RESULT" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin).get('errors', [])))" 2>/dev/null || echo "[]")
286
+
287
+ if [ "$BOARD_PULL_CREATED" -gt 0 ]; then
288
+ echo " Reconstructed ${BOARD_PULL_CREATED} issue(s) from board state"
289
+ echo "$PULL_RESULT" | python3 -c "
290
+ import json, sys
291
+ for item in json.load(sys.stdin).get('created', []):
292
+ print(f' ✓ #{item[\"number\"]} ({item[\"stage\"]}): {item[\"title\"][:60]}')
293
+ " 2>/dev/null
294
+ fi
295
+
296
+ # Offer drift resolution if any issues have mismatched stages
297
+ DRIFT_COUNT=$(echo "$BOARD_PULL_DRIFT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0")
298
+
299
+ if [ "$DRIFT_COUNT" -gt 0 ]; then
300
+ echo ""
301
+ echo "Stage drift detected (board vs local):"
302
+ echo "$BOARD_PULL_DRIFT" | python3 -c "
303
+ import json, sys
304
+ for d in json.load(sys.stdin):
305
+ print(f' #{d[\"number\"]} {d[\"title\"][:45]}: local={d[\"local\"]} board={d[\"board\"]}')
306
+ " 2>/dev/null
307
+ echo ""
308
+
309
+ AskUserQuestion(
310
+ header: "Stage Drift",
311
+ question: "Board and local state disagree on pipeline stage for ${DRIFT_COUNT} issue(s). How should we resolve?",
312
+ options: [
313
+ { label: "Pull from board", description: "Update all local files to match board stages (board is source of truth)" },
314
+ { label: "Keep local", description: "Leave local stages as-is — board will be updated next time pipeline runs" },
315
+ { label: "Skip", description: "Ignore drift for now — flag in report only" }
316
+ ]
317
+ )
318
+
319
+ if [ "$USER_CHOICE" = "Pull from board" ]; then
320
+ echo "$BOARD_PULL_DRIFT" | python3 -c "
321
+ import json, sys, os
322
+
323
+ drift = json.load(sys.stdin)
324
+ active_dir = '${ACTIVE_DIR}'
325
+
326
+ for d in drift:
327
+ num = d['number']
328
+ board_stage = d['board']
329
+ for fname in os.listdir(active_dir):
330
+ if fname.startswith(f'{num}-') and fname.endswith('.json'):
331
+ path = os.path.join(active_dir, fname)
332
+ state = json.load(open(path))
333
+ state['pipeline_stage'] = board_stage
334
+ with open(path, 'w') as f:
335
+ json.dump(state, f, indent=2)
336
+ print(f' Updated #{num}: {d[\"local\"]} → {board_stage}')
337
+ break
338
+ " 2>/dev/null
339
+ BOARD_PULL_DRIFT="[]" # Resolved
340
+ fi
341
+ fi
342
+ fi
343
+ ```
344
+
345
+ **Note:** `reconstructed_from_board: true` is set on any state file created this way.
346
+ Downstream commands (`/mgw:run`, `/mgw:issue`) will see this flag and know the state was
347
+ rebuilt from board data — triage results and GSD artifacts will need to be re-run if the
348
+ issue advances to `planning` or beyond.
349
+ </step>
350
+
31
351
  <step name="scan_active">
32
352
  **Scan all active issue states:**
33
353
 
@@ -214,11 +534,13 @@ git push origin --delete ${BRANCH_NAME} 2>/dev/null
214
534
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
215
535
 
216
536
  Active: ${active_count} issues in progress
537
+ Pulled: ${BOARD_PULL_CREATED} reconstructed from board (0 if board not configured)
217
538
  Completed: ${completed_count} archived
218
539
  Stale: ${stale_count} need attention
219
540
  Orphaned: ${orphaned_count} need attention
220
541
  Comments: ${comment_drift_count} issues with unreviewed comments
221
542
  Branches: ${deleted_count} cleaned up
543
+ ${BOARD_DISCOVERED ? 'Board: discovered + registered ' + BOARD_DISCOVERED : ''}
222
544
  ${HEALTH ? 'GSD Health: ' + HEALTH.status : ''}
223
545
 
224
546
  ${details_for_each_non_active_item}
@@ -231,7 +553,16 @@ ${gsd_milestone_consistency ? 'GSD Milestone Links:\n' + gsd_milestone_consisten
231
553
  </process>
232
554
 
233
555
  <success_criteria>
234
- - [ ] All .mgw/active/ files scanned
556
+ - [ ] pull_board_state runs before scan_active
557
+ - [ ] Board discovery: if project_board.node_id empty, findExistingBoard() + getProjectFields() called; if found, registered in project.json
558
+ - [ ] Board pull: all board items fetched in one GraphQL call; issues with no local .mgw/active/ file are reconstructed from GitHub issue data + board Status
559
+ - [ ] Reconstructed files have reconstructed_from_board:true, pipeline_stage from board Status, gsd_route from board GSD Route field
560
+ - [ ] "Done" board items are skipped (not written to active/)
561
+ - [ ] Stage drift detected and reported: local pipeline_stage differs from board Status
562
+ - [ ] Drift resolution offered: pull from board / keep local / skip
563
+ - [ ] Board pull errors (failed gh issue view calls) are collected and shown in report, never abort sync
564
+ - [ ] BOARD_PULL_CREATED count shown in sync report
565
+ - [ ] All .mgw/active/ files scanned (including any reconstructed by pull_board_state)
235
566
  - [ ] GitHub state checked for each issue, PR, branch
236
567
  - [ ] Comment delta checked for each active issue
237
568
  - [ ] GSD milestone consistency checked for all maps-to links
package/dist/bin/mgw.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- var claude = require('../claude-Vp9qvImH.cjs');
4
+ var claude = require('../claude-Dk1oVsaG.cjs');
5
5
  var require$$0 = require('commander');
6
6
  var require$$1 = require('path');
7
7
  var require$$0$2 = require('fs');
@@ -9,7 +9,7 @@ var require$$0$1 = require('child_process');
9
9
 
10
10
  var mgw$1 = {};
11
11
 
12
- var version = "0.1.1";
12
+ var version = "0.1.2";
13
13
  var require$$8 = {
14
14
  version: version};
15
15
 
@@ -107,6 +107,40 @@ function requireState () {
107
107
  if (changed) {
108
108
  writeProjectState(existing);
109
109
  }
110
+ const activeDir = getActiveDir();
111
+ if (fs.existsSync(activeDir)) {
112
+ let entries;
113
+ try {
114
+ entries = fs.readdirSync(activeDir);
115
+ } catch {
116
+ entries = [];
117
+ }
118
+ for (const file of entries) {
119
+ if (!file.endsWith(".json")) continue;
120
+ const filePath = path.join(activeDir, file);
121
+ let issueState;
122
+ try {
123
+ issueState = JSON.parse(fs.readFileSync(filePath, "utf-8"));
124
+ } catch {
125
+ continue;
126
+ }
127
+ let issueChanged = false;
128
+ if (!issueState.hasOwnProperty("retry_count")) {
129
+ issueState.retry_count = 0;
130
+ issueChanged = true;
131
+ }
132
+ if (!issueState.hasOwnProperty("dead_letter")) {
133
+ issueState.dead_letter = false;
134
+ issueChanged = true;
135
+ }
136
+ if (issueChanged) {
137
+ try {
138
+ fs.writeFileSync(filePath, JSON.stringify(issueState, null, 2), "utf-8");
139
+ } catch {
140
+ }
141
+ }
142
+ }
143
+ }
110
144
  return existing;
111
145
  }
112
146
  function resolveActiveMilestoneIndex(state) {
@@ -197,6 +231,125 @@ function requireGithub () {
197
231
  if (o.prerelease) cmd += " --prerelease";
198
232
  return run(cmd);
199
233
  }
234
+ function getProjectNodeId(owner, projectNumber) {
235
+ const userQuery = `'query($login: String!, $number: Int!) { user(login: $login) { projectV2(number: $number) { id } } }'`;
236
+ const orgQuery = `'query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { id } } }'`;
237
+ try {
238
+ const raw = run(
239
+ `gh api graphql -f query=${userQuery} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.user.projectV2.id'`
240
+ );
241
+ if (raw && raw !== "null") return raw;
242
+ } catch (_) {
243
+ }
244
+ try {
245
+ const raw = run(
246
+ `gh api graphql -f query=${orgQuery} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.organization.projectV2.id'`
247
+ );
248
+ if (raw && raw !== "null") return raw;
249
+ } catch (_) {
250
+ }
251
+ return null;
252
+ }
253
+ function findExistingBoard(owner, titlePattern) {
254
+ const pattern = titlePattern.toLowerCase();
255
+ try {
256
+ const raw = run(
257
+ `gh api graphql -f query='query($login: String!) { user(login: $login) { projectsV2(first: 20) { nodes { id number url title } } } }' -f login=${JSON.stringify(owner)} --jq '.data.user.projectsV2.nodes'`
258
+ );
259
+ const nodes = JSON.parse(raw);
260
+ const match = nodes.find((n) => n.title.toLowerCase().includes(pattern));
261
+ if (match) return { number: match.number, url: match.url, nodeId: match.id, title: match.title };
262
+ } catch (_) {
263
+ }
264
+ try {
265
+ const raw = run(
266
+ `gh api graphql -f query='query($login: String!) { organization(login: $login) { projectsV2(first: 20) { nodes { id number url title } } } }' -f login=${JSON.stringify(owner)} --jq '.data.organization.projectsV2.nodes'`
267
+ );
268
+ const nodes = JSON.parse(raw);
269
+ const match = nodes.find((n) => n.title.toLowerCase().includes(pattern));
270
+ if (match) return { number: match.number, url: match.url, nodeId: match.id, title: match.title };
271
+ } catch (_) {
272
+ }
273
+ return null;
274
+ }
275
+ function getProjectFields(owner, projectNumber) {
276
+ const query = `'query($login: String!, $number: Int!) { user(login: $login) { projectV2(number: $number) { fields(first: 20) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } ... on ProjectV2Field { id name dataType } } } } } }'`;
277
+ const orgQuery = `'query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { fields(first: 20) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } ... on ProjectV2Field { id name dataType } } } } } }'`;
278
+ let raw;
279
+ try {
280
+ raw = run(`gh api graphql -f query=${query} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.user.projectV2.fields.nodes'`);
281
+ } catch (_) {
282
+ try {
283
+ raw = run(`gh api graphql -f query=${orgQuery} -f login=${JSON.stringify(owner)} -F number=${projectNumber} --jq '.data.organization.projectV2.fields.nodes'`);
284
+ } catch (_2) {
285
+ return null;
286
+ }
287
+ }
288
+ const nodes = JSON.parse(raw);
289
+ const fields = {};
290
+ const statusNode = nodes.find((n) => n.name === "Status" && n.options);
291
+ if (statusNode) {
292
+ const stageMap = {
293
+ "new": "New",
294
+ "triaged": "Triaged",
295
+ "needs-info": "Needs Info",
296
+ "needs-security-review": "Needs Security Review",
297
+ "discussing": "Discussing",
298
+ "approved": "Approved",
299
+ "planning": "Planning",
300
+ "executing": "Executing",
301
+ "verifying": "Verifying",
302
+ "pr-created": "PR Created",
303
+ "done": "Done",
304
+ "failed": "Failed",
305
+ "blocked": "Blocked"
306
+ };
307
+ const nameToId = Object.fromEntries(statusNode.options.map((o) => [o.name, o.id]));
308
+ fields.status = {
309
+ field_id: statusNode.id,
310
+ field_name: "Status",
311
+ type: "SINGLE_SELECT",
312
+ options: Object.fromEntries(
313
+ Object.entries(stageMap).map(([stage, label]) => [stage, nameToId[label] || ""])
314
+ )
315
+ };
316
+ }
317
+ const aiNode = nodes.find((n) => n.name === "AI Agent State" && !n.options);
318
+ if (aiNode) {
319
+ fields.ai_agent_state = { field_id: aiNode.id, field_name: "AI Agent State", type: "TEXT" };
320
+ }
321
+ const phaseNode = nodes.find((n) => n.name === "Phase" && !n.options);
322
+ if (phaseNode) {
323
+ fields.phase = { field_id: phaseNode.id, field_name: "Phase", type: "TEXT" };
324
+ }
325
+ const routeNode = nodes.find((n) => n.name === "GSD Route" && n.options);
326
+ if (routeNode) {
327
+ const routeMap = {
328
+ "gsd:quick": "quick",
329
+ "gsd:quick --full": "quick --full",
330
+ "gsd:plan-phase": "plan-phase",
331
+ "gsd:new-milestone": "new-milestone"
332
+ };
333
+ const nameToId = Object.fromEntries(routeNode.options.map((o) => [o.name, o.id]));
334
+ fields.gsd_route = {
335
+ field_id: routeNode.id,
336
+ field_name: "GSD Route",
337
+ type: "SINGLE_SELECT",
338
+ options: Object.fromEntries(
339
+ Object.entries(routeMap).map(([route, label]) => [route, nameToId[label] || ""])
340
+ )
341
+ };
342
+ }
343
+ const milestoneNode = nodes.find((n) => n.name === "Milestone");
344
+ if (milestoneNode) {
345
+ fields.milestone = {
346
+ field_id: milestoneNode.id,
347
+ field_name: "Milestone",
348
+ type: milestoneNode.dataType || (milestoneNode.options ? "SINGLE_SELECT" : "TEXT")
349
+ };
350
+ }
351
+ return Object.keys(fields).length > 0 ? fields : null;
352
+ }
200
353
  function createProject(owner, title) {
201
354
  const raw = run(
202
355
  `gh project create --owner ${JSON.stringify(owner)} --title ${JSON.stringify(title)} --format json`
@@ -287,6 +440,9 @@ function requireGithub () {
287
440
  getRateLimit,
288
441
  closeMilestone,
289
442
  createRelease,
443
+ getProjectNodeId,
444
+ findExistingBoard,
445
+ getProjectFields,
290
446
  createProject,
291
447
  addItemToProject,
292
448
  postMilestoneStartAnnouncement