ai-dev-workflow 0.1.0__py3-none-any.whl

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.
ai_workflow_cli.py ADDED
@@ -0,0 +1,761 @@
1
+ """CLI bootstrap for ai-dev-workflow."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ try:
10
+ import yaml
11
+ except ImportError:
12
+ yaml = None
13
+
14
+ from ado_provider import AzureDevOps, build_state_mapping, validate_mapping, validate_field_mapping
15
+ from work_item_provider import REQUIRED_AGENT_ROLES, REQUIRED_CANONICAL_STATES
16
+ from setup_wizard import build_setup_plan, discover_provider, interactive_mapping, interactive_ado_field_mapping, render_user_steps, apply_github_status_bootstrap
17
+ from github_work_item_provider import validate_github_mapping
18
+ from ai_provider import provider_template, validate_provider_registry
19
+ from source_connection import resolve_github_token
20
+ from work_item_registry import validate_work_item_provider
21
+ from ado_mcp import build_ado_mcp
22
+ from worker_registry import build_worker, validate_worker_registry
23
+ from framework_update import DEFAULT_FRAMEWORK_SOURCE, ensure_framework_metadata, build_update_plan, apply_update
24
+
25
+
26
+ DEFAULT_ROLE_PROVIDERS = {
27
+ "SetupAgent": "manual",
28
+ "ArchitectAgent": "manual",
29
+ "TesterAgent": "codex",
30
+ "ImplementerAgent": "codex",
31
+ "ReviewerAgent": "manual",
32
+ "RuntimeVerifierAgent": "project-defined",
33
+ }
34
+
35
+ PACKAGE_VERSION = "0.1.0"
36
+
37
+ ROLE_KEYS = {
38
+ "SetupAgent": "setup",
39
+ "ArchitectAgent": "architect",
40
+ "TesterAgent": "tester",
41
+ "ImplementerAgent": "implementer",
42
+ "ReviewerAgent": "reviewer",
43
+ "RuntimeVerifierAgent": "runtime_verifier",
44
+ }
45
+
46
+
47
+ def _load_yaml(path: Path) -> dict:
48
+ if yaml is None:
49
+ raise RuntimeError("PyYAML is required")
50
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
51
+ return data or {}
52
+
53
+
54
+ def _dump_yaml(data: dict) -> str:
55
+ if yaml is None:
56
+ raise RuntimeError("PyYAML is required")
57
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
58
+
59
+
60
+ def build_agents(role_providers: dict | None = None) -> dict:
61
+ role_providers = role_providers or DEFAULT_ROLE_PROVIDERS
62
+ return {
63
+ ROLE_KEYS[role]: {"role": role, "provider": provider}
64
+ for role, provider in role_providers.items()
65
+ }
66
+
67
+
68
+ def build_ado_config(discovery: dict, role_providers: dict | None = None) -> dict:
69
+ return {
70
+ "version": 1,
71
+ "work_items": {
72
+ "provider": "azure-devops",
73
+ "azure_devops": {
74
+ "organization": discovery["organization"],
75
+ "project": discovery["project"],
76
+ "project_id": discovery["project_id"],
77
+ "process_id": discovery["process_id"],
78
+ "state_mapping": build_state_mapping(discovery),
79
+ },
80
+ },
81
+ "agents": build_agents(role_providers),
82
+ }
83
+
84
+
85
+ def build_github_config(role_providers: dict | None = None) -> dict:
86
+ return {
87
+ "version": 1,
88
+ "work_items": {"provider": "github-issues"},
89
+ "agents": build_agents(role_providers),
90
+ }
91
+
92
+
93
+ def merge_owned(existing: dict, generated: dict) -> dict:
94
+ merged = dict(existing)
95
+ merged["version"] = existing.get("version", generated.get("version", 1))
96
+ for key in ("work_items", "agents", "execution_environment"):
97
+ if key in generated:
98
+ merged[key] = generated[key]
99
+ if "framework" in generated:
100
+ merged["framework"] = {
101
+ **generated["framework"],
102
+ **(existing.get("framework") or {}),
103
+ "source": generated["framework"]["source"],
104
+ "ref": generated["framework"]["ref"],
105
+ "schema_version": generated["framework"]["schema_version"],
106
+ }
107
+ return merged
108
+
109
+
110
+ def ensure_file(path: Path, content: str, force: bool = False) -> bool:
111
+ if path.exists() and not force:
112
+ return False
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ path.write_text(content, encoding="utf-8")
115
+ return True
116
+
117
+
118
+ def generated_agents_md() -> str:
119
+ return """# AGENTS.md
120
+
121
+ This repository uses ai-dev-workflow.
122
+
123
+ Start from CURRENT.md when present, then load only context relevant to the assigned role and task.
124
+ Treat the configured WorkItemProvider as the task source of truth.
125
+ Do not change frozen acceptance criteria merely to make tests pass.
126
+ Runtime verification must not be inferred from mocks.
127
+ """
128
+
129
+
130
+ def generated_current_md() -> str:
131
+ return """# CURRENT
132
+
133
+ ## Task
134
+ None
135
+
136
+ ## Status
137
+ Idle
138
+
139
+ ## Required Context
140
+ AGENTS.md
141
+
142
+ ## Relevant Code
143
+ None
144
+
145
+ ## Allowed Scope
146
+ Project-defined
147
+
148
+ ## Done When
149
+ Project-defined
150
+
151
+ ## Completed
152
+ None
153
+
154
+ ## Next Action
155
+ Select or publish a work item.
156
+
157
+ ## Blocker
158
+ None
159
+ """
160
+
161
+
162
+ def generated_devcontainer() -> str:
163
+ return """{
164
+ "name": "AI Workflow Workspace",
165
+ "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
166
+ "features": {
167
+ "ghcr.io/devcontainers/features/node:1": {
168
+ "version": "lts"
169
+ }
170
+ },
171
+ "postCreateCommand": "python -m pip install --user PyYAML",
172
+ "remoteUser": "vscode"
173
+ }
174
+ """
175
+
176
+
177
+ def parse_framework_ref(framework_ref: str) -> tuple[str, str]:
178
+ marker = "/.github/workflows/"
179
+ if marker not in framework_ref or "@" not in framework_ref:
180
+ return DEFAULT_FRAMEWORK_SOURCE, "main"
181
+ source = framework_ref.split(marker, 1)[0]
182
+ ref = framework_ref.rsplit("@", 1)[1]
183
+ return source, ref
184
+
185
+
186
+ def generated_github_caller(framework_ref: str) -> str:
187
+ return f"""name: AI Workflow Queue
188
+
189
+ on:
190
+ issues:
191
+ types: [labeled, closed, unassigned]
192
+ workflow_dispatch:
193
+ schedule:
194
+ - cron: '*/10 * * * *'
195
+
196
+ permissions:
197
+ contents: write
198
+ issues: write
199
+ pull-requests: write
200
+
201
+ jobs:
202
+ dispatch:
203
+ if: github.event_name != 'issues' || github.event.action != 'labeled' || github.event.label.name == 'status:ready'
204
+ uses: {framework_ref}
205
+ with:
206
+ codex_cloud_enabled: false
207
+ codespaces_enabled: false
208
+ codespaces_mode: on-demand
209
+ """
210
+
211
+
212
+ def doctor(config: dict, discovery: dict | None = None) -> list[str]:
213
+ errors = []
214
+ work_items = config.get("work_items", {})
215
+ provider = work_items.get("provider")
216
+ if provider not in ("github-issues", "azure-devops", "composite"):
217
+ errors.append("work_items.provider must be github-issues, azure-devops or composite")
218
+ if provider == "github-issues" and "github_issues" in work_items:
219
+ github = work_items.get("github_issues", {})
220
+ mapping = github.get("state_mapping", {})
221
+ if discovery is not None and discovery.get("labels") is not None:
222
+ errors.extend(validate_github_mapping(discovery, mapping))
223
+ canonical = set(mapping.values())
224
+ for required in REQUIRED_CANONICAL_STATES:
225
+ if required not in canonical:
226
+ errors.append(f"canonical state is not mapped by any GitHub status: {required}")
227
+ if provider in ("azure-devops", "composite"):
228
+ ado = work_items.get("azure_devops", {})
229
+ for field in ("organization", "project", "state_mapping"):
230
+ if not ado.get(field):
231
+ errors.append(f"missing Azure DevOps config: {field}")
232
+ mapping = ado.get("state_mapping", {})
233
+ if discovery is not None:
234
+ errors.extend(validate_mapping(discovery, mapping))
235
+ canonical = {value for wit in mapping.values() for value in wit.values()}
236
+ if provider == "azure-devops":
237
+ for required in REQUIRED_CANONICAL_STATES:
238
+ if required not in canonical:
239
+ errors.append(f"canonical state is not mapped by any ADO state: {required}")
240
+ if discovery is not None:
241
+ errors.extend(validate_field_mapping(discovery, ado.get("field_mapping", {})))
242
+
243
+ agents = config.get("agents", {})
244
+ by_role = {
245
+ item.get("role"): item.get("provider")
246
+ for item in agents.values()
247
+ if isinstance(item, dict)
248
+ }
249
+ for role in REQUIRED_AGENT_ROLES:
250
+ provider_name = by_role.get(role)
251
+ if not provider_name or provider_name == "none":
252
+ errors.append(f"required agent role has no provider: {role}")
253
+
254
+ providers = config.get("providers", {})
255
+ if providers:
256
+ errors.extend(validate_provider_registry(providers, agents))
257
+
258
+ workers = config.get("workers", [])
259
+ if workers:
260
+ errors.extend(validate_worker_registry(workers, providers))
261
+
262
+ env = config.get("execution_environment", {})
263
+ if env:
264
+ env_type = env.get("type")
265
+ if env_type not in ("none", "codespaces"):
266
+ errors.append("execution_environment.type must be none or codespaces")
267
+ if env_type == "codespaces":
268
+ mode = (env.get("codespaces") or {}).get("mode")
269
+ if mode not in ("off", "on-demand", "always-on"):
270
+ errors.append("codespaces mode must be off, on-demand or always-on")
271
+ return errors
272
+
273
+
274
+ def parse_role_provider(values: list[str]) -> dict:
275
+ result = dict(DEFAULT_ROLE_PROVIDERS)
276
+ for item in values:
277
+ if "=" not in item:
278
+ raise ValueError("role provider must use Role=provider")
279
+ role, provider = item.split("=", 1)
280
+ role, provider = role.strip(), provider.strip()
281
+ if role not in DEFAULT_ROLE_PROVIDERS or not provider:
282
+ raise ValueError(f"invalid role provider assignment: {item}")
283
+ result[role] = provider
284
+ return result
285
+
286
+
287
+ def parse_ai_provider(values: list[str]) -> dict:
288
+ providers = {
289
+ "manual": provider_template("interactive"),
290
+ "codex": provider_template("chatgpt-codex"),
291
+ }
292
+ for raw in values:
293
+ if "=" not in raw:
294
+ raise ValueError("ai provider must use id=type[,key=value...]")
295
+ provider_id, spec = raw.split("=", 1)
296
+ provider_id = provider_id.strip()
297
+ parts = [p.strip() for p in spec.split(",") if p.strip()]
298
+ if not provider_id or not parts:
299
+ raise ValueError(f"invalid ai provider: {raw}")
300
+ provider_type = parts[0]
301
+ options = {}
302
+ for item in parts[1:]:
303
+ if "=" not in item:
304
+ raise ValueError(f"invalid ai provider option: {item}")
305
+ key, value = item.split("=", 1)
306
+ options[key.strip()] = value.strip()
307
+ providers[provider_id] = provider_template(
308
+ provider_type,
309
+ model=options.get("model"),
310
+ base_url=options.get("base_url"),
311
+ auth_type=options.get("auth"),
312
+ env=options.get("env"),
313
+ )
314
+ return providers
315
+
316
+
317
+ def parse_worker(values: list[str]) -> list[dict]:
318
+ workers = []
319
+ for raw in values:
320
+ if "=" not in raw:
321
+ raise ValueError("worker must use id=mode,provider=...,environment=...,roles=A|B")
322
+ worker_id, spec = raw.split("=", 1)
323
+ worker_id = worker_id.strip()
324
+ parts = [p.strip() for p in spec.split(",") if p.strip()]
325
+ if not worker_id or not parts:
326
+ raise ValueError(f"invalid worker: {raw}")
327
+ mode = parts[0]
328
+ options = {}
329
+ for item in parts[1:]:
330
+ if "=" not in item:
331
+ raise ValueError(f"invalid worker option: {item}")
332
+ key, value = item.split("=", 1)
333
+ options[key.strip()] = value.strip()
334
+ roles = [x for x in options.get("roles", "").split("|") if x]
335
+ capabilities = [x for x in options.get("capabilities", "").split("|") if x]
336
+ managed = options.get("managed")
337
+ managed_value = None if managed is None else managed.lower() in ("1","true","yes","on")
338
+ workers.append(build_worker(
339
+ worker_id,
340
+ mode=mode,
341
+ provider=options.get("provider", "manual"),
342
+ environment=options.get("environment", "local"),
343
+ roles=roles,
344
+ capabilities=capabilities,
345
+ capacity=int(options.get("capacity", "1")),
346
+ managed=managed_value,
347
+ assignee=options.get("assignee"),
348
+ planning_updates=options.get("planning_updates", "approval-required"),
349
+ ))
350
+ return workers
351
+
352
+
353
+ def parse_ado_field_mapping(values: list[str]) -> dict:
354
+ """Parse WIT:key=Field.Ref[,default=value]. Values are user-defined; fields must already exist."""
355
+ result = {}
356
+ for raw in values:
357
+ if "=" not in raw or ":" not in raw.split("=",1)[0]:
358
+ raise ValueError("ado field must use WIT:key=Field.Ref[,default=value]")
359
+ left, spec = raw.split("=",1)
360
+ wit, key = left.split(":",1)
361
+ parts = [p.strip() for p in spec.split(",") if p.strip()]
362
+ field_ref = parts[0]
363
+ config = {"field": field_ref}
364
+ for item in parts[1:]:
365
+ if "=" not in item:
366
+ raise ValueError(f"invalid ado field option: {item}")
367
+ k, v = item.split("=",1)
368
+ if k.strip() != "default":
369
+ raise ValueError(f"unsupported ado field option: {k}")
370
+ config["default"] = v
371
+ result.setdefault(wit.strip(), {})[key.strip()] = config
372
+ return result
373
+
374
+
375
+ def build_environment(environment: str, codespaces_mode: str) -> dict:
376
+ if environment == "none":
377
+ return {"type": "none"}
378
+ return {
379
+ "type": "codespaces",
380
+ "codespaces": {
381
+ "enabled": codespaces_mode != "off",
382
+ "mode": codespaces_mode,
383
+ },
384
+ }
385
+
386
+
387
+ def perform_init(args, ado_api_factory=AzureDevOps) -> tuple[dict, dict | None]:
388
+ roles = parse_role_provider(args.role_provider)
389
+ discovery = None
390
+ if args.work_items == "azure-devops":
391
+ token = os.environ.get(args.ado_token_env)
392
+ if not token:
393
+ raise RuntimeError(f"{args.ado_token_env} is required")
394
+ api = ado_api_factory(args.ado_organization, token)
395
+ discovery = api.discover(args.ado_project)
396
+ generated = build_ado_config(discovery, roles)
397
+ else:
398
+ generated = build_github_config(roles)
399
+
400
+ generated["execution_environment"] = build_environment(
401
+ args.environment, args.codespaces_mode
402
+ )
403
+ framework_source, framework_version = parse_framework_ref(args.framework_ref)
404
+ generated = ensure_framework_metadata(
405
+ generated, source=framework_source, ref=framework_version
406
+ )
407
+
408
+ output = Path(args.output)
409
+ existing = _load_yaml(output) if output.exists() else {}
410
+ config = generated if args.force or not existing else merge_owned(existing, generated)
411
+ output.write_text(_dump_yaml(config), encoding="utf-8")
412
+
413
+ if discovery is not None:
414
+ discovery_path = output.with_suffix(output.suffix + ".ado-discovery.json")
415
+ discovery_path.write_text(
416
+ json.dumps(discovery, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
417
+ encoding="utf-8",
418
+ )
419
+
420
+ if not args.no_files:
421
+ ensure_file(Path("AGENTS.md"), generated_agents_md(), args.force)
422
+ ensure_file(Path("CURRENT.md"), generated_current_md(), args.force)
423
+ if args.environment == "codespaces":
424
+ ensure_file(
425
+ Path(".devcontainer/devcontainer.json"),
426
+ generated_devcontainer(),
427
+ args.force,
428
+ )
429
+ if args.work_items == "github-issues":
430
+ ensure_file(
431
+ Path(".github/workflows/ai-workflow.yml"),
432
+ generated_github_caller(args.framework_ref),
433
+ args.force,
434
+ )
435
+
436
+ return config, discovery
437
+
438
+
439
+ def init_general(args) -> int:
440
+ config, discovery = perform_init(args)
441
+ errors = doctor(config, discovery)
442
+ print(f"Wrote {args.output}")
443
+ if errors:
444
+ print("Doctor findings:")
445
+ for error in errors:
446
+ print("- " + error)
447
+ return 2
448
+ print("Doctor: OK")
449
+ return 0
450
+
451
+
452
+ def init_ado(args) -> int:
453
+ class Compat:
454
+ pass
455
+ compat = Compat()
456
+ compat.work_items = "azure-devops"
457
+ compat.ado_organization = args.organization
458
+ compat.ado_project = args.project
459
+ compat.ado_token_env = args.token_env
460
+ compat.role_provider = args.role_provider
461
+ compat.output = args.output
462
+ compat.force = args.force
463
+ compat.environment = "none"
464
+ compat.codespaces_mode = "off"
465
+ compat.no_files = True
466
+ compat.framework_ref = ""
467
+ config, discovery = perform_init(compat)
468
+ errors = doctor(config, discovery)
469
+ discovery_path = Path(args.output).with_suffix(Path(args.output).suffix + ".ado-discovery.json")
470
+ print(f"Wrote {args.output}")
471
+ print(f"Wrote {discovery_path}")
472
+ if errors:
473
+ print("Doctor findings:")
474
+ for error in errors:
475
+ print("- " + error)
476
+ return 2
477
+ print("Doctor: OK")
478
+ return 0
479
+
480
+
481
+ def setup_cmd(args) -> int:
482
+ github_auth = None
483
+ if args.github_repository:
484
+ _, github_auth = resolve_github_token(args.github_auth, args.github_token_env)
485
+ provider_errors = validate_work_item_provider(args.work_items)
486
+ if provider_errors:
487
+ raise RuntimeError(provider_errors[0])
488
+
489
+ roles = parse_role_provider(args.role_provider)
490
+ agents = build_agents(roles)
491
+ providers = parse_ai_provider(args.ai_provider)
492
+ workers = parse_worker(args.worker)
493
+ environment = build_environment(args.environment, args.codespaces_mode)
494
+
495
+ discovery, suggestions = discover_provider(args)
496
+ ado_field_mapping = parse_ado_field_mapping(args.ado_field)
497
+ ado_mcp = None
498
+ if args.work_items in ("azure-devops", "composite") and args.ado_mcp_mode != "none":
499
+ ado_mcp = build_ado_mcp(
500
+ args.ado_organization,
501
+ mode=args.ado_mcp_mode,
502
+ auth=args.ado_mcp_auth,
503
+ project=args.ado_project,
504
+ team=args.ado_team,
505
+ )
506
+ mapping = suggestions
507
+ if not args.non_interactive:
508
+ print("Step 1/10: source host connected")
509
+ print("Step 2/10: repository context analyzed")
510
+ print("Step 3/10: work item provider selected:", args.work_items)
511
+ print("Step 4/10: work item provider connected")
512
+ print("Step 5/10: existing statuses discovered")
513
+ print("Step 6/10: confirm status mapping")
514
+ print("Step 8/10: workers registered:", len(workers))
515
+ mapping = interactive_mapping("azure-devops" if args.work_items == "composite" else args.work_items, discovery, suggestions)
516
+ if args.work_items in ("azure-devops", "composite") and not ado_field_mapping:
517
+ ado_field_mapping = interactive_ado_field_mapping(discovery)
518
+
519
+ workflow = {
520
+ "tdd": args.tdd,
521
+ "reviewer": {"mode": args.reviewer_mode},
522
+ "runtime_verification": {"mode": args.runtime_mode},
523
+ }
524
+ plan = build_setup_plan(
525
+ root=args.root,
526
+ work_items=args.work_items,
527
+ discovery=discovery,
528
+ mapping=mapping,
529
+ workflow=workflow,
530
+ agents=agents,
531
+ environment=environment,
532
+ providers=providers,
533
+ github_auth=github_auth,
534
+ ado_mcp=ado_mcp,
535
+ workers=workers,
536
+ ado_field_mapping=ado_field_mapping,
537
+ )
538
+ plan["work_items"]["mapping_confirmed"] = not args.plan_only
539
+ Path(args.plan_output).write_text(_dump_yaml(plan), encoding="utf-8")
540
+ print(render_user_steps(plan))
541
+ print(f"\nWrote setup plan: {args.plan_output}")
542
+
543
+ if args.plan_only:
544
+ return 0
545
+
546
+ if args.work_items == "github-issues":
547
+ discovery, mapping, created = apply_github_status_bootstrap(args, mapping)
548
+ if created:
549
+ print("Created managed GitHub status labels: " + ", ".join(created))
550
+
551
+ class InitArgs:
552
+ pass
553
+ init = InitArgs()
554
+ init.work_items = "azure-devops" if args.work_items == "composite" else args.work_items
555
+ init.ado_organization = args.ado_organization
556
+ init.ado_project = args.ado_project
557
+ init.ado_token_env = args.ado_token_env
558
+ init.role_provider = args.role_provider
559
+ init.output = args.output
560
+ init.force = args.force
561
+ init.environment = args.environment
562
+ init.codespaces_mode = args.codespaces_mode
563
+ init.no_files = args.no_files
564
+ init.framework_ref = args.framework_ref
565
+
566
+ config, _ = perform_init(init)
567
+ config["workflow"] = workflow
568
+ config["providers"] = providers
569
+ config["workers"] = workers
570
+ config["source_connection"] = {"github": github_auth or {}}
571
+ if ado_mcp:
572
+ config["mcp"] = {"azure_devops": ado_mcp}
573
+ if args.work_items == "github-issues":
574
+ config["work_items"]["github_issues"] = {
575
+ "repository": args.github_repository,
576
+ "state_mapping": mapping,
577
+ }
578
+ else:
579
+ config["work_items"]["azure_devops"]["state_mapping"] = mapping
580
+ config["work_items"]["azure_devops"]["field_mapping"] = ado_field_mapping
581
+ if args.work_items == "composite":
582
+ config["work_items"]["provider"] = "composite"
583
+ config["work_items"]["github_issues"] = {"repository": args.github_repository}
584
+ config["work_items"]["sync"] = {
585
+ "business_provider": "azure-devops",
586
+ "execution_provider": "github-issues",
587
+ "state_mode": "coarse",
588
+ }
589
+ framework_source, framework_version = parse_framework_ref(args.framework_ref)
590
+ config = ensure_framework_metadata(
591
+ config, source=framework_source, ref=framework_version
592
+ )
593
+ config["framework"]["source"] = framework_source
594
+ config["framework"]["ref"] = framework_version
595
+ Path(args.output).write_text(_dump_yaml(config), encoding="utf-8")
596
+ errors = doctor(config, discovery)
597
+ if errors:
598
+ print("Doctor findings:")
599
+ for error in errors:
600
+ print("- " + error)
601
+ return 2
602
+ print("Step 10/10: Doctor: OK")
603
+ return 0
604
+
605
+
606
+ def update_cmd(args) -> int:
607
+ root = Path(args.root)
608
+ config_path = Path(args.config)
609
+ if not config_path.is_absolute():
610
+ config_path = root / config_path
611
+ if not config_path.exists():
612
+ raise RuntimeError(f"config not found: {config_path}")
613
+ config = _load_yaml(config_path)
614
+ meta = ensure_framework_metadata(config).get("framework", {})
615
+ target_ref = args.to or "main"
616
+ target_source = args.source or meta.get("source", DEFAULT_FRAMEWORK_SOURCE)
617
+ plan = build_update_plan(
618
+ config,
619
+ target_ref=target_ref,
620
+ target_source=target_source,
621
+ )
622
+
623
+ print(
624
+ f"Framework: {plan['current']['source']}@{plan['current']['ref']} "
625
+ f"-> {plan['target']['source']}@{plan['target']['ref']}"
626
+ )
627
+ print("Update available:", "yes" if plan["update_available"] else "no")
628
+ if args.check:
629
+ return 0
630
+
631
+ workflow_ref = (
632
+ f"{target_source}/.github/workflows/queue-dispatcher.yml@{target_ref}"
633
+ )
634
+ workflow = generated_github_caller(workflow_ref)
635
+ current_workflow_ref = (
636
+ f"{plan['current']['source']}/.github/workflows/queue-dispatcher.yml@"
637
+ f"{plan['current']['ref']}"
638
+ )
639
+ expected_current_workflow = generated_github_caller(current_workflow_ref)
640
+ updated, report = apply_update(
641
+ config,
642
+ target_ref=target_ref,
643
+ target_source=target_source,
644
+ generated_workflow=workflow,
645
+ expected_current_workflow=expected_current_workflow,
646
+ root=args.root,
647
+ force=args.force,
648
+ )
649
+ config_path.write_text(_dump_yaml(updated), encoding="utf-8")
650
+
651
+ print("Migrations:", ", ".join(report["migrations"]) or "none")
652
+ print("Changed files:", ", ".join(report["changed_files"]) or "none")
653
+ print("Preserved project files:", ", ".join(report["preserved_project_files"]))
654
+
655
+ errors = doctor(updated)
656
+ if errors:
657
+ print("Doctor findings:")
658
+ for error in errors:
659
+ print("- " + error)
660
+ return 2
661
+ print("Doctor: OK")
662
+ return 0
663
+
664
+
665
+ def doctor_cmd(args) -> int:
666
+ config = _load_yaml(Path(args.config))
667
+ discovery = None
668
+ if args.discovery:
669
+ discovery = json.loads(Path(args.discovery).read_text(encoding="utf-8"))
670
+ errors = doctor(config, discovery)
671
+ if errors:
672
+ for error in errors:
673
+ print("ERROR: " + error)
674
+ return 2
675
+ print("Doctor: OK")
676
+ return 0
677
+
678
+
679
+ def main() -> int:
680
+ parser = argparse.ArgumentParser(prog="ai-workflow")
681
+ parser.add_argument("--version", action="version", version=f"ai-workflow {PACKAGE_VERSION}")
682
+ sub = parser.add_subparsers(dest="command", required=True)
683
+
684
+ init = sub.add_parser("init")
685
+ init.add_argument("--work-items", choices=["github-issues", "azure-devops"], default="github-issues")
686
+ init.add_argument("--ado-organization")
687
+ init.add_argument("--ado-project")
688
+ init.add_argument("--ado-token-env", default="AZURE_DEVOPS_TOKEN")
689
+ init.add_argument("--environment", choices=["none", "codespaces"], default="none")
690
+ init.add_argument("--codespaces-mode", choices=["off", "on-demand", "always-on"], default="on-demand")
691
+ init.add_argument("--role-provider", action="append", default=[])
692
+ init.add_argument("--output", default=".ai-workflow.yml")
693
+ init.add_argument("--framework-ref", default="vuthethienlong/ai-dev-workflow/.github/workflows/queue-dispatcher.yml@main")
694
+ init.add_argument("--no-files", action="store_true")
695
+ init.add_argument("--force", action="store_true")
696
+ init.set_defaults(func=init_general)
697
+
698
+ init_ado_parser = sub.add_parser("init-ado")
699
+ init_ado_parser.add_argument("--organization", required=True)
700
+ init_ado_parser.add_argument("--project", required=True)
701
+ init_ado_parser.add_argument("--output", default=".ai-workflow.yml")
702
+ init_ado_parser.add_argument("--token-env", default="AZURE_DEVOPS_TOKEN")
703
+ init_ado_parser.add_argument("--role-provider", action="append", default=[])
704
+ init_ado_parser.add_argument("--force", action="store_true")
705
+ init_ado_parser.set_defaults(func=init_ado)
706
+
707
+ setup = sub.add_parser("setup")
708
+ setup.add_argument("--root", default=".")
709
+ setup.add_argument("--work-items", choices=["github-issues", "azure-devops", "composite"], default="github-issues")
710
+ setup.add_argument("--github-repository")
711
+ setup.add_argument("--github-token-env", default="GH_TOKEN")
712
+ setup.add_argument("--github-auth", choices=["gh-cli","token-env"], default="gh-cli")
713
+ setup.add_argument("--ado-organization")
714
+ setup.add_argument("--ado-project")
715
+ setup.add_argument("--ado-token-env", default="AZURE_DEVOPS_TOKEN")
716
+ setup.add_argument("--ado-mcp-mode", choices=["none","remote","local"], default="remote")
717
+ setup.add_argument("--ado-mcp-auth", choices=["interactive","azcli","env","envvar","pat"], default="interactive")
718
+ setup.add_argument("--ado-team")
719
+ setup.add_argument("--ado-field", action="append", default=[])
720
+ setup.add_argument("--environment", choices=["none", "codespaces"], default="none")
721
+ setup.add_argument("--codespaces-mode", choices=["off", "on-demand", "always-on"], default="on-demand")
722
+ setup.add_argument("--role-provider", action="append", default=[])
723
+ setup.add_argument("--ai-provider", action="append", default=[])
724
+ setup.add_argument("--worker", action="append", default=[])
725
+ setup.add_argument("--tdd", action=argparse.BooleanOptionalAction, default=True)
726
+ setup.add_argument("--reviewer-mode", choices=["manual", "automatic", "none"], default="manual")
727
+ setup.add_argument("--runtime-mode", choices=["project-defined", "batched", "none"], default="project-defined")
728
+ setup.add_argument("--output", default=".ai-workflow.yml")
729
+ setup.add_argument("--plan-output", default=".ai-setup-plan.yml")
730
+ setup.add_argument("--framework-ref", default="vuthethienlong/ai-dev-workflow/.github/workflows/queue-dispatcher.yml@main")
731
+ setup.add_argument("--plan-only", action="store_true")
732
+ setup.add_argument("--non-interactive", action="store_true")
733
+ setup.add_argument("--no-files", action="store_true")
734
+ setup.add_argument("--force", action="store_true")
735
+ setup.set_defaults(func=setup_cmd)
736
+
737
+ update = sub.add_parser("update")
738
+ update.add_argument("--config", default=".ai-workflow.yml")
739
+ update.add_argument("--root", default=".")
740
+ update.add_argument("--check", action="store_true")
741
+ update.add_argument("--to")
742
+ update.add_argument("--source")
743
+ update.add_argument("--force", action="store_true")
744
+ update.set_defaults(func=update_cmd)
745
+
746
+ doc = sub.add_parser("doctor")
747
+ doc.add_argument("--config", default=".ai-workflow.yml")
748
+ doc.add_argument("--discovery")
749
+ doc.set_defaults(func=doctor_cmd)
750
+
751
+ args = parser.parse_args()
752
+ if args.command in ("init", "setup") and args.work_items in ("azure-devops", "composite"):
753
+ if not args.ado_organization or not args.ado_project:
754
+ parser.error("--ado-organization and --ado-project are required for azure-devops")
755
+ if args.command == "setup" and args.work_items in ("github-issues", "composite") and not args.github_repository:
756
+ parser.error("--github-repository is required for GitHub status discovery")
757
+ return args.func(args)
758
+
759
+
760
+ if __name__ == "__main__":
761
+ raise SystemExit(main())