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.
planning_context.py ADDED
@@ -0,0 +1,151 @@
1
+ """Normalize and validate provider-neutral workspace planning context."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ try:
10
+ import yaml
11
+ except ImportError: # pragma: no cover - CLI dependency error
12
+ yaml = None
13
+
14
+ REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
15
+ REF = re.compile(r"^[A-Za-z0-9._/-]+$")
16
+ ACCESS = {"read", "read-write"}
17
+
18
+
19
+ def _text(value, field):
20
+ if not isinstance(value, str) or not value.strip():
21
+ raise ValueError(f"{field} must be a non-empty string")
22
+ return value.strip()
23
+
24
+
25
+ def normalize_workspace(raw: dict) -> dict:
26
+ if not isinstance(raw, dict) or raw.get("version") != 1:
27
+ raise ValueError("workspace version must be 1")
28
+
29
+ workspace = raw.get("workspace")
30
+ if not isinstance(workspace, dict):
31
+ raise ValueError("workspace section is required")
32
+ workspace_name = _text(workspace.get("name"), "workspace.name")
33
+
34
+ project = raw.get("github_project")
35
+ normalized_project = None
36
+ if project is not None:
37
+ if not isinstance(project, dict):
38
+ raise ValueError("github_project must be an object")
39
+ owner = _text(project.get("owner"), "github_project.owner")
40
+ number = project.get("number")
41
+ if type(number) is not int or number < 1:
42
+ raise ValueError("github_project.number must be a positive integer")
43
+ normalized_project = {"owner": owner, "number": number}
44
+
45
+ repos = raw.get("repositories")
46
+ if not isinstance(repos, list) or not repos:
47
+ raise ValueError("repositories must be a non-empty list")
48
+
49
+ seen = set()
50
+ normalized_repos = []
51
+ for index, item in enumerate(repos):
52
+ if not isinstance(item, dict):
53
+ raise ValueError(f"repositories[{index}] must be an object")
54
+ repository = _text(item.get("repository"), f"repositories[{index}].repository")
55
+ if not REPOSITORY.fullmatch(repository):
56
+ raise ValueError(f"invalid repository: {repository}")
57
+ if repository in seen:
58
+ raise ValueError(f"duplicate repository: {repository}")
59
+ seen.add(repository)
60
+
61
+ purpose = _text(item.get("purpose"), f"repositories[{index}].purpose")
62
+ ref = _text(item.get("ref", "main"), f"repositories[{index}].ref")
63
+ if not REF.fullmatch(ref) or ".." in ref or ref.startswith("/") or ref.endswith("/"):
64
+ raise ValueError(f"invalid ref for {repository}: {ref}")
65
+ access = item.get("access", "read")
66
+ if access not in ACCESS:
67
+ raise ValueError(f"invalid access for {repository}: {access}")
68
+ context = item.get("context", [])
69
+ if not isinstance(context, list) or any(not isinstance(p, str) or not p.strip() for p in context):
70
+ raise ValueError(f"context for {repository} must be a list of non-empty paths")
71
+ normalized_repos.append({
72
+ "repository": repository,
73
+ "purpose": purpose,
74
+ "ref": ref,
75
+ "access": access,
76
+ "context": [p.strip() for p in context],
77
+ })
78
+
79
+ planning = raw.get("planning", {})
80
+ if not isinstance(planning, dict):
81
+ raise ValueError("planning must be an object")
82
+ default_repo = planning.get("default_repository") or normalized_repos[0]["repository"]
83
+ if default_repo not in seen:
84
+ raise ValueError("planning.default_repository must exist in repositories")
85
+
86
+ initiatives = raw.get("initiatives", {})
87
+ if not isinstance(initiatives, dict):
88
+ raise ValueError("initiatives must be an object")
89
+ normalized_initiatives = {}
90
+ for key, value in sorted(initiatives.items()):
91
+ if not isinstance(key, str) or not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", key):
92
+ raise ValueError(f"invalid initiative key: {key}")
93
+ if not isinstance(value, dict):
94
+ raise ValueError(f"initiative {key} must be an object")
95
+ title = _text(value.get("title"), f"initiatives.{key}.title")
96
+ required_issues = value.get("required_issues", [])
97
+ if not isinstance(required_issues, list) or any(
98
+ not isinstance(item, str) or not item.strip() for item in required_issues
99
+ ):
100
+ raise ValueError(f"initiatives.{key}.required_issues must be a list of non-empty strings")
101
+ completion = value.get("completion", {})
102
+ if not isinstance(completion, dict):
103
+ raise ValueError(f"initiatives.{key}.completion must be an object")
104
+ require_all = completion.get("require_all_issues_closed", True)
105
+ require_runtime = completion.get("require_runtime_pass", False)
106
+ if type(require_all) is not bool or type(require_runtime) is not bool:
107
+ raise ValueError(f"initiatives.{key}.completion values must be booleans")
108
+ normalized_initiatives[key] = {
109
+ "title": title,
110
+ "required_issues": [item.strip() for item in required_issues],
111
+ "completion": {
112
+ "require_all_issues_closed": require_all,
113
+ "require_runtime_pass": require_runtime,
114
+ },
115
+ }
116
+
117
+ return {
118
+ "version": 1,
119
+ "planning_context": {
120
+ "workspace": workspace_name,
121
+ "github_project": normalized_project,
122
+ "default_repository": default_repo,
123
+ "repositories": sorted(normalized_repos, key=lambda x: x["repository"]),
124
+ "initiatives": normalized_initiatives,
125
+ },
126
+ }
127
+
128
+
129
+ def load_workspace(path: Path) -> dict:
130
+ if yaml is None:
131
+ raise RuntimeError("PyYAML is required to read .ai-workspace.yml")
132
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
133
+ return normalize_workspace(data)
134
+
135
+
136
+ def main() -> int:
137
+ parser = argparse.ArgumentParser()
138
+ parser.add_argument("workspace", type=Path)
139
+ parser.add_argument("--output", type=Path)
140
+ args = parser.parse_args()
141
+ result = load_workspace(args.workspace)
142
+ content = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
143
+ if args.output:
144
+ args.output.write_text(content, encoding="utf-8")
145
+ else:
146
+ print(content, end="")
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
setup_context.py ADDED
@@ -0,0 +1,99 @@
1
+ """Repository context analysis for first-run workflow setup."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ SIGNALS = {
8
+ "agents_file": "AGENTS.md",
9
+ "current_router": "CURRENT.md",
10
+ "workflow_config": ".ai-workflow.yml",
11
+ "workspace_config": ".ai-workspace.yml",
12
+ "devcontainer": ".devcontainer/devcontainer.json",
13
+ }
14
+
15
+ DOC_GLOBS = ("docs/**/*.md", "*.md")
16
+ TEST_DIRS = ("tests", "test", "__tests__", "spec")
17
+ WORKFLOW_DIR = ".github/workflows"
18
+
19
+
20
+ def analyze_repository(root: Path | str = ".") -> dict:
21
+ root = Path(root)
22
+ signals = {name: (root / rel).exists() for name, rel in SIGNALS.items()}
23
+ docs = set()
24
+ for pattern in DOC_GLOBS:
25
+ for path in root.glob(pattern):
26
+ if path.is_file():
27
+ try:
28
+ docs.add(str(path.relative_to(root)))
29
+ except ValueError:
30
+ pass
31
+
32
+ tests = []
33
+ for dirname in TEST_DIRS:
34
+ path = root / dirname
35
+ if path.exists():
36
+ tests.append(dirname)
37
+
38
+ workflows = []
39
+ workflow_dir = root / WORKFLOW_DIR
40
+ if workflow_dir.exists():
41
+ for path in sorted(workflow_dir.glob("*.y*ml")):
42
+ workflows.append(str(path.relative_to(root)))
43
+
44
+ languages = []
45
+ indicators = {
46
+ "python": ("pyproject.toml", "requirements.txt", "setup.py"),
47
+ "node": ("package.json",),
48
+ "dotnet": ("*.sln", "*.csproj"),
49
+ "rust": ("Cargo.toml",),
50
+ "go": ("go.mod",),
51
+ }
52
+ for language, patterns in indicators.items():
53
+ found = False
54
+ for pattern in patterns:
55
+ if "*" in pattern:
56
+ found = any(root.glob(pattern))
57
+ else:
58
+ found = (root / pattern).exists()
59
+ if found:
60
+ languages.append(language)
61
+ break
62
+
63
+ recommendations = []
64
+ if not signals["agents_file"]:
65
+ recommendations.append("create AGENTS.md")
66
+ if not signals["current_router"]:
67
+ recommendations.append("create CURRENT.md")
68
+ if not signals["workflow_config"]:
69
+ recommendations.append("create .ai-workflow.yml")
70
+ if not signals["devcontainer"]:
71
+ recommendations.append("create devcontainer when Codespaces is enabled")
72
+ if not tests:
73
+ recommendations.append("define project test command/conventions")
74
+
75
+ return {
76
+ "root": str(root.resolve()),
77
+ "signals": signals,
78
+ "languages": languages,
79
+ "tests": tests,
80
+ "workflows": workflows,
81
+ "docs": sorted(docs)[:100],
82
+ "recommendations": recommendations,
83
+ }
84
+
85
+
86
+ def setup_agent_context(analysis: dict) -> dict:
87
+ """Provider-neutral input contract for a SetupAgent."""
88
+ return {
89
+ "role": "SetupAgent",
90
+ "goal": "Recommend initial ai-dev-workflow configuration without changing project intent.",
91
+ "repository_context": analysis,
92
+ "expected_output": {
93
+ "workflow_policy": "recommended TDD/reviewer/runtime policy",
94
+ "role_assignments": "recommended provider per agent role",
95
+ "execution_environment": "none or codespaces",
96
+ "context_files": "files the agents should load first",
97
+ "manual_setup": "credentials/settings the user must configure",
98
+ },
99
+ }
setup_wizard.py ADDED
@@ -0,0 +1,221 @@
1
+ """Step-by-step repository setup for ai-dev-workflow."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from ado_provider import AzureDevOps, build_state_mapping, available_fields
8
+ from github_work_item_provider import GitHubIssues, build_github_mapping, ensure_managed_status_labels
9
+ from setup_context import analyze_repository, setup_agent_context
10
+ from ai_provider import credential_requirements_for_providers, provider_template
11
+ from source_connection import resolve_github_token
12
+ from ado_mcp import build_ado_mcp, ado_mcp_credentials
13
+ from worker_registry import worker_credential_requirements
14
+
15
+ CANONICAL_HELP = (
16
+ "draft, ready, in-progress, in-review, runtime-pending, "
17
+ "release-pending, blocked, completed"
18
+ )
19
+
20
+ DEFAULT_WORKFLOW = {
21
+ "tdd": True,
22
+ "reviewer": {"mode": "manual"},
23
+ "runtime_verification": {"mode": "project-defined"},
24
+ }
25
+
26
+
27
+ def credential_requirements(work_items: str, environment: str, agents: dict, providers: dict | None = None, github_auth=None, ado_mcp=None, workers: list[dict] | None = None) -> list[dict]:
28
+ requirements = []
29
+ if work_items in ("github-issues", "composite"):
30
+ if (github_auth or {}).get("type") == "gh-cli-oauth":
31
+ requirements.append({
32
+ "name": "GitHub CLI OAuth session",
33
+ "purpose": "Authenticate GitHub API through existing gh auth login session.",
34
+ "required_for_setup": True,
35
+ "store": "GitHub CLI credential store",
36
+ })
37
+ else:
38
+ requirements.append({
39
+ "name": (github_auth or {}).get("env", "GH_TOKEN"),
40
+ "purpose": "Authenticate GitHub API using token environment fallback.",
41
+ "required_for_setup": True,
42
+ "store": "environment or GitHub Actions secret; never commit",
43
+ })
44
+ if work_items in ("azure-devops", "composite"):
45
+ requirements.append({
46
+ "name": "AZURE_DEVOPS_TOKEN",
47
+ "purpose": "Discover ADO process/WIT states and manage work items.",
48
+ "required_for_setup": True,
49
+ "store": "environment/secret store; never commit",
50
+ })
51
+ if environment == "codespaces":
52
+ requirements.append({
53
+ "name": "CODESPACES_TOKEN",
54
+ "purpose": "Provision/start/stop GitHub Codespaces worker environments.",
55
+ "required_for_setup": False,
56
+ "store": "GitHub Actions repository secret",
57
+ })
58
+ requirements.extend(credential_requirements_for_providers(providers or {}))
59
+ requirements.extend(ado_mcp_credentials(ado_mcp or {}))
60
+ requirements.extend(worker_credential_requirements(workers or [], work_items))
61
+ return requirements
62
+
63
+
64
+ def build_setup_plan(
65
+ root=".",
66
+ work_items="github-issues",
67
+ discovery=None,
68
+ mapping=None,
69
+ workflow=None,
70
+ agents=None,
71
+ environment=None,
72
+ providers=None,
73
+ github_auth=None,
74
+ ado_mcp=None,
75
+ workers=None,
76
+ ado_field_mapping=None,
77
+ ) -> dict:
78
+ analysis = analyze_repository(root)
79
+ if providers is None:
80
+ providers = {}
81
+ referenced = {item.get("provider") for item in (agents or {}).values() if isinstance(item, dict)}
82
+ if "codex" in referenced:
83
+ providers["codex"] = provider_template("chatgpt-codex")
84
+ if "manual" in referenced:
85
+ providers["manual"] = provider_template("interactive")
86
+ return {
87
+ "version": 1,
88
+ "status": "review-required",
89
+ "phases": [
90
+ {"id": 1, "name": "connect-source-host", "status": "complete"},
91
+ {"id": 2, "name": "analyze-context", "status": "complete"},
92
+ {"id": 3, "name": "select-work-item-provider", "status": "complete"},
93
+ {"id": 4, "name": "connect-work-item-provider", "status": "complete"},
94
+ {"id": 5, "name": "discover-existing-statuses", "status": "complete" if discovery is not None else "pending"},
95
+ {"id": 6, "name": "confirm-status-mapping", "status": "review-required"},
96
+ {"id": 7, "name": "configure-workflow-and-agents", "status": "review-required"},
97
+ {"id": 8, "name": "register-workers", "status": "review-required"},
98
+ {"id": 9, "name": "configure-environment-and-credentials", "status": "review-required"},
99
+ {"id": 10, "name": "doctor-and-apply", "status": "pending"},
100
+ ],
101
+ "context": analysis,
102
+ "setup_agent": setup_agent_context(analysis),
103
+ "source_connection": {"github": github_auth or {}},
104
+ "work_items": {
105
+ "provider": work_items,
106
+ "discovery": discovery,
107
+ "suggested_mapping": mapping or {},
108
+ "mapping_confirmed": False,
109
+ "ado_field_mapping": ado_field_mapping or {},
110
+ },
111
+ "workflow": workflow or DEFAULT_WORKFLOW,
112
+ "agents": agents or {},
113
+ "providers": providers or {},
114
+ "ado_mcp": ado_mcp or {},
115
+ "workers": workers or [],
116
+ "execution_environment": environment or {"type": "none"},
117
+ "credential_requirements": credential_requirements(
118
+ work_items,
119
+ (environment or {"type": "none"}).get("type", "none"),
120
+ agents or {},
121
+ providers or {},
122
+ github_auth,
123
+ ado_mcp,
124
+ workers or [],
125
+ ),
126
+ }
127
+
128
+
129
+ def discover_provider(args):
130
+ if args.work_items in ("azure-devops", "composite"):
131
+ token = os.environ.get(args.ado_token_env)
132
+ if not token:
133
+ raise RuntimeError(f"{args.ado_token_env} is required for ADO discovery")
134
+ discovery = AzureDevOps(args.ado_organization, token).discover(args.ado_project)
135
+ return discovery, build_state_mapping(discovery)
136
+
137
+ token, _ = resolve_github_token(args.github_auth, args.github_token_env)
138
+ discovery = GitHubIssues(args.github_repository, token).discover()
139
+ return discovery, build_github_mapping(discovery)
140
+
141
+
142
+ def interactive_ado_field_mapping(discovery: dict, input_fn=input, output_fn=print) -> dict:
143
+ result = {}
144
+ output_fn("\nADO existing-field mapping (existing fields only; setup will not create fields)")
145
+ for wit in discovery.get("work_item_types", []):
146
+ ref = wit["reference_name"]
147
+ fields = available_fields(discovery, ref)
148
+ if not fields:
149
+ continue
150
+ output_fn(f"\nWork Item Type: {wit['name']} ({ref})")
151
+ by_ref = {f["reference_name"]: f for f in fields}
152
+ configured = {}
153
+ for key in ("workflow_phase","github_issue","worker","agent_role","pull_request","test_status","runtime_status","workflow_profile"):
154
+ field_ref = input_fn(f" Map {key} to existing field reference (blank=skip): ").strip()
155
+ if not field_ref:
156
+ continue
157
+ if field_ref not in by_ref:
158
+ raise ValueError(f"ADO field does not exist for {ref}: {field_ref}")
159
+ default = input_fn(f" Default value for {key} (blank=no default): ")
160
+ item = {"field": field_ref}
161
+ if default != "":
162
+ item["default"] = default
163
+ configured[key] = item
164
+ if configured:
165
+ result[ref] = configured
166
+ return result
167
+
168
+
169
+ def interactive_mapping(provider: str, discovery: dict, suggestions: dict, input_fn=input, output_fn=print):
170
+ output_fn(f"Canonical states: {CANONICAL_HELP}")
171
+ if provider == "azure-devops":
172
+ result = {}
173
+ for wit in discovery.get("work_item_types", []):
174
+ ref = wit["reference_name"]
175
+ result[ref] = {}
176
+ output_fn(f"\nWork Item Type: {wit['name']} ({ref})")
177
+ for state in wit.get("states", []):
178
+ if state.get("hidden"):
179
+ continue
180
+ name = state["name"]
181
+ default = suggestions.get(ref, {}).get(name, "draft")
182
+ value = input_fn(f" {name} [{default}]: ").strip() or default
183
+ result[ref][name] = value
184
+ return result
185
+
186
+ result = {}
187
+ labels = discovery.get("labels", [])
188
+ for item in labels:
189
+ name = item["name"]
190
+ default = suggestions.get(name)
191
+ prompt_default = default or "skip"
192
+ value = input_fn(f" {name} [{prompt_default}]: ").strip()
193
+ if not value:
194
+ value = default
195
+ if value and value != "skip":
196
+ result[name] = value
197
+ return result
198
+
199
+
200
+ def apply_github_status_bootstrap(args, mapping: dict) -> tuple[dict, dict, list[str]]:
201
+ token, _ = resolve_github_token(args.github_auth, args.github_token_env)
202
+ api = GitHubIssues(args.github_repository, token)
203
+ before = api.discover()
204
+ created = ensure_managed_status_labels(api, before)
205
+ after = api.discover() if created else before
206
+ refreshed = build_github_mapping(after)
207
+ refreshed.update(mapping)
208
+ return after, refreshed, created
209
+
210
+
211
+ def render_user_steps(plan: dict) -> str:
212
+ lines = ["Setup phases:"]
213
+ for phase in plan["phases"]:
214
+ lines.append(f"{phase['id']}. {phase['name']} — {phase['status']}")
215
+ lines.append("")
216
+ lines.append("Manual credential/settings checklist:")
217
+ if not plan["credential_requirements"]:
218
+ lines.append("- none")
219
+ for item in plan["credential_requirements"]:
220
+ lines.append(f"- {item['name']}: {item['purpose']} Store: {item['store']}.")
221
+ return "\n".join(lines)
source_connection.py ADDED
@@ -0,0 +1,21 @@
1
+ """Source-host connection helpers."""
2
+ from __future__ import annotations
3
+ import os, subprocess
4
+
5
+
6
+ def resolve_github_token(mode: str, env_name: str = "GH_TOKEN") -> tuple[str, dict]:
7
+ if mode == "gh-cli":
8
+ proc = subprocess.run(
9
+ ["gh", "auth", "token"],
10
+ check=True, capture_output=True, text=True,
11
+ )
12
+ token = proc.stdout.strip()
13
+ if not token:
14
+ raise RuntimeError("gh auth token returned an empty token")
15
+ return token, {"type": "gh-cli-oauth"}
16
+ if mode == "token-env":
17
+ token = os.environ.get(env_name)
18
+ if not token:
19
+ raise RuntimeError(f"{env_name} is required")
20
+ return token, {"type": "token-env", "env": env_name}
21
+ raise ValueError(f"unsupported GitHub auth mode: {mode}")
work_item_provider.py ADDED
@@ -0,0 +1,20 @@
1
+ """Provider-neutral work item state model."""
2
+ CANONICAL_STATES = (
3
+ "draft",
4
+ "ready",
5
+ "in-progress",
6
+ "in-review",
7
+ "runtime-pending",
8
+ "release-pending",
9
+ "blocked",
10
+ "completed",
11
+ )
12
+
13
+ REQUIRED_CANONICAL_STATES = ("ready", "completed")
14
+ SETUP_AGENT_ROLE = "SetupAgent"
15
+
16
+ REQUIRED_AGENT_ROLES = (
17
+ "ArchitectAgent",
18
+ "TesterAgent",
19
+ "ImplementerAgent",
20
+ )
work_item_registry.py ADDED
@@ -0,0 +1,25 @@
1
+ """Registry of WorkItemProvider adapters used by setup."""
2
+ BUILTIN_WORK_ITEM_PROVIDERS = {
3
+ "github-issues": {
4
+ "type": "github-issues",
5
+ "requires_source_connection": "github",
6
+ },
7
+ "azure-devops": {
8
+ "type": "azure-devops",
9
+ "requires_source_connection": None,
10
+ "supports_mcp": True,
11
+ },
12
+ "composite": {
13
+ "type": "composite",
14
+ "business_provider": "azure-devops",
15
+ "execution_provider": "github-issues",
16
+ "supports_mcp": True,
17
+ },
18
+ }
19
+
20
+
21
+ def validate_work_item_provider(provider_id: str, registry=None) -> list[str]:
22
+ registry = registry or BUILTIN_WORK_ITEM_PROVIDERS
23
+ if provider_id not in registry:
24
+ return [f"unknown work item provider: {provider_id}"]
25
+ return []