tagteam 0.3.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.
Files changed (69) hide show
  1. tagteam/__init__.py +13 -0
  2. tagteam/__main__.py +6 -0
  3. tagteam/cli.py +418 -0
  4. tagteam/config.py +185 -0
  5. tagteam/cycle.py +607 -0
  6. tagteam/data/.claude/skills/handoff/SKILL.md +153 -0
  7. tagteam/data/checklists/code_review.md +43 -0
  8. tagteam/data/checklists/plan_review.md +34 -0
  9. tagteam/data/templates/cycle.md +32 -0
  10. tagteam/data/templates/decision_log.md +26 -0
  11. tagteam/data/templates/feedback.md +42 -0
  12. tagteam/data/templates/handoff_impl.md +36 -0
  13. tagteam/data/templates/handoff_plan.md +37 -0
  14. tagteam/data/templates/implementation_log.md +23 -0
  15. tagteam/data/templates/phase_plan.md +44 -0
  16. tagteam/data/templates/requirements_brief.md +24 -0
  17. tagteam/data/templates/roadmap.md +39 -0
  18. tagteam/data/templates/sync_state.md +33 -0
  19. tagteam/data/web/app.js +1177 -0
  20. tagteam/data/web/conversation.js +669 -0
  21. tagteam/data/web/index.html +179 -0
  22. tagteam/data/web/sprites.js +379 -0
  23. tagteam/data/web/styles.css +840 -0
  24. tagteam/data/workflows.md +219 -0
  25. tagteam/iterm.py +417 -0
  26. tagteam/migrate.py +115 -0
  27. tagteam/parser.py +240 -0
  28. tagteam/registry.py +62 -0
  29. tagteam/roadmap.py +184 -0
  30. tagteam/server.py +540 -0
  31. tagteam/session.py +418 -0
  32. tagteam/setup.py +226 -0
  33. tagteam/state.py +597 -0
  34. tagteam/templates.py +42 -0
  35. tagteam/tui/__init__.py +52 -0
  36. tagteam/tui/__main__.py +8 -0
  37. tagteam/tui/app.py +513 -0
  38. tagteam/tui/art/__init__.py +1 -0
  39. tagteam/tui/art/clock.py +16 -0
  40. tagteam/tui/art/mayor.py +36 -0
  41. tagteam/tui/art/rabbit.py +33 -0
  42. tagteam/tui/art/saloon.py +61 -0
  43. tagteam/tui/characters.py +34 -0
  44. tagteam/tui/clock_widget.py +98 -0
  45. tagteam/tui/conversation.py +96 -0
  46. tagteam/tui/conversations/__init__.py +1 -0
  47. tagteam/tui/conversations/intro.py +206 -0
  48. tagteam/tui/conversations/transitions.py +86 -0
  49. tagteam/tui/dialogue.py +322 -0
  50. tagteam/tui/handoff_reader.py +69 -0
  51. tagteam/tui/map_data.py +216 -0
  52. tagteam/tui/map_widget.py +142 -0
  53. tagteam/tui/review_dialogue.py +192 -0
  54. tagteam/tui/review_replay.py +89 -0
  55. tagteam/tui/scene.py +212 -0
  56. tagteam/tui/sound.py +40 -0
  57. tagteam/tui/sounds/bell.wav +0 -0
  58. tagteam/tui/sounds/chime.wav +0 -0
  59. tagteam/tui/sounds/coo.wav +0 -0
  60. tagteam/tui/sounds/stamp.wav +0 -0
  61. tagteam/tui/sounds/tick.wav +0 -0
  62. tagteam/tui/state_watcher.py +116 -0
  63. tagteam/tui/status_bar.py +90 -0
  64. tagteam/watcher.py +762 -0
  65. tagteam-0.3.0.dist-info/METADATA +178 -0
  66. tagteam-0.3.0.dist-info/RECORD +69 -0
  67. tagteam-0.3.0.dist-info/WHEEL +5 -0
  68. tagteam-0.3.0.dist-info/entry_points.txt +3 -0
  69. tagteam-0.3.0.dist-info/top_level.txt +1 -0
tagteam/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ Tagteam
3
+
4
+ A collaboration framework for structured AI-to-AI handoffs with human oversight.
5
+ Configure your lead and reviewer agents via tagteam.yaml.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
9
+
10
+ try:
11
+ __version__ = _pkg_version("tagteam")
12
+ except PackageNotFoundError:
13
+ __version__ = "0.0.0+unknown"
tagteam/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """CLI entrypoint for python -m tagteam"""
2
+ import sys
3
+ from tagteam.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
tagteam/cli.py ADDED
@@ -0,0 +1,418 @@
1
+ """
2
+ CLI for Tagteam.
3
+
4
+ Usage:
5
+ python -m tagteam init - Initialize agent configuration
6
+ python -m tagteam setup [dir] - Copy framework files to a project
7
+ python -m tagteam migrate - Migrate legacy projects to use config
8
+ python -m tagteam watch - Start the watcher daemon
9
+ python -m tagteam state - View/update orchestration state
10
+ python -m tagteam session - Manage orchestration sessions
11
+ python -m tagteam serve - Start the web dashboard server
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from tagteam.config import read_config
20
+
21
+
22
+ CONFIG_TEMPLATE = """# Tagteam Configuration
23
+ # Defines the two AI agents and their roles in the collaboration workflow.
24
+
25
+ agents:
26
+ lead:
27
+ name: {lead_name}
28
+ reviewer:
29
+ name: {reviewer_name}
30
+ """
31
+
32
+ HANDOFF_EXPLAINER = """
33
+ How the handoff works:
34
+
35
+ Lead (one AI agent) plans each phase and implements the approved plan.
36
+ Reviewer (a second AI agent) reviews both the plan and the implementation.
37
+ Arbiter (you, the human) breaks ties and approves phases.
38
+
39
+ Work progresses phase-by-phase. Each phase is listed in docs/roadmap.md and
40
+ goes through two review cycles: plan, then implementation. If the two agents
41
+ can't make progress in 10 rounds, control escalates to the human arbiter.
42
+
43
+ State is tracked in handoff-state.json (current turn) and
44
+ docs/handoffs/<phase>_<type>_rounds.jsonl plus <phase>_<type>_status.json
45
+ (per-cycle rounds). Either agent can pick up where the other left off at
46
+ any time.
47
+ """
48
+
49
+ GETTING_STARTED = """
50
+ Getting Started
51
+ ===============
52
+ Start a session with agents and watcher (run from project root):
53
+
54
+ tagteam session start
55
+
56
+ If you are on Windows or another unsupported platform, use the manual backend:
57
+
58
+ tagteam session start --backend manual
59
+ tagteam watch --mode notify
60
+
61
+ Or use quickstart (runs setup + init + session with backend auto-detection):
62
+
63
+ tagteam quickstart
64
+ """
65
+
66
+
67
+ def prompt_input(
68
+ prompt: str,
69
+ valid_options: list[str] | None = None,
70
+ lowercase: bool = True,
71
+ ) -> str:
72
+ """Get user input with optional validation."""
73
+ while True:
74
+ raw_value = input(prompt).strip()
75
+ if not raw_value:
76
+ print(" Please enter a value.")
77
+ continue
78
+
79
+ check_value = raw_value.lower()
80
+ if valid_options and check_value not in valid_options:
81
+ print(f" Please enter one of: {', '.join(valid_options)}")
82
+ continue
83
+
84
+ return check_value if lowercase else raw_value
85
+
86
+
87
+ def write_config(target_dir: str, lead_name: str, reviewer_name: str) -> Path:
88
+ """Write tagteam.yaml to target_dir. Non-interactive."""
89
+ config_path = Path(target_dir) / "tagteam.yaml"
90
+ config_content = CONFIG_TEMPLATE.format(
91
+ lead_name=lead_name,
92
+ reviewer_name=reviewer_name,
93
+ )
94
+ config_path.write_text(config_content, encoding="utf-8")
95
+ return config_path
96
+
97
+
98
+ def needs_init(project_dir: str = ".") -> bool:
99
+ """Check if agent configuration is needed."""
100
+ return not (Path(project_dir) / "tagteam.yaml").exists()
101
+
102
+
103
+ def run_init(project_dir: str = ".", show_explainer: bool = False) -> bool:
104
+ """Run interactive init if config is missing. Requires TTY.
105
+
106
+ show_explainer=False by default so callers like quickstart can print the
107
+ explainer themselves exactly once. Standalone CLI dispatch passes True.
108
+ """
109
+ if not needs_init(project_dir):
110
+ print("Agent configuration already exists; skipping init.")
111
+ return True
112
+
113
+ if not sys.stdin.isatty():
114
+ print("Error: No tagteam.yaml found and stdin is not interactive.")
115
+ print(" Run 'tagteam init' interactively first.")
116
+ return False
117
+
118
+ import os
119
+
120
+ original_dir = os.getcwd()
121
+ try:
122
+ os.chdir(project_dir)
123
+ init_command(show_explainer=show_explainer)
124
+ finally:
125
+ os.chdir(original_dir)
126
+ return True
127
+
128
+
129
+ def init_command(show_explainer: bool = True) -> int:
130
+ """Interactive init command to create tagteam.yaml.
131
+
132
+ Prompts for two agent names: lead first, reviewer second. No role prompt —
133
+ order defines role.
134
+ """
135
+ config_path = Path("tagteam.yaml")
136
+
137
+ print()
138
+ print("Tagteam Setup")
139
+ print("================")
140
+ print("This framework coordinates work between two AI agents.")
141
+ print()
142
+
143
+ if config_path.exists():
144
+ existing = read_config(config_path)
145
+ if existing:
146
+ agents = existing.get("agents", {})
147
+ lead = agents.get("lead", {}).get("name", "unknown")
148
+ reviewer = agents.get("reviewer", {}).get("name", "unknown")
149
+
150
+ print("tagteam.yaml already exists with:")
151
+ print(f" Lead: {lead}")
152
+ print(f" Reviewer: {reviewer}")
153
+ else:
154
+ print("tagteam.yaml already exists but could not be parsed.")
155
+ print("(File may be empty or malformed)")
156
+
157
+ print()
158
+ overwrite = prompt_input("Overwrite? (y/n): ", ["y", "n", "yes", "no"])
159
+ if overwrite not in ["y", "yes"]:
160
+ print("Aborted.")
161
+ return 0
162
+ print()
163
+
164
+ print("Enter the names of your two AI agents (first is Lead, second is Reviewer).")
165
+ print()
166
+
167
+ lead_name = prompt_input("Lead agent name: ", lowercase=False)
168
+ reviewer_name = prompt_input("Reviewer agent name: ", lowercase=False)
169
+ print()
170
+
171
+ write_config(".", lead_name, reviewer_name)
172
+
173
+ print("Created tagteam.yaml")
174
+ print(f" Lead: {lead_name}")
175
+ print(f" Reviewer: {reviewer_name}")
176
+
177
+ if show_explainer:
178
+ print(HANDOFF_EXPLAINER)
179
+ print(GETTING_STARTED)
180
+ return 0
181
+
182
+
183
+ def setup_command(target_dir: str = ".") -> int:
184
+ """Copy framework files to target directory."""
185
+ from tagteam.setup import main as setup_main
186
+
187
+ setup_main(target_dir)
188
+ return 0
189
+
190
+
191
+ _BACKEND_SURFACE = {
192
+ "iterm2": "tab",
193
+ "tmux": "pane",
194
+ "manual": "terminal",
195
+ }
196
+
197
+
198
+ def _print_priming_box(lead_name: str, reviewer_name: str, surface: str) -> None:
199
+ """Print a boxed 'SESSION READY' message with backend-appropriate terminology."""
200
+ prime_body = (
201
+ "Read tagteam.yaml and .claude/skills/handoff/"
202
+ "SKILL.md, then type /handoff"
203
+ )
204
+ lines = [
205
+ "SESSION READY",
206
+ "",
207
+ f"In the Lead {surface}, tell {lead_name}:",
208
+ f' "{prime_body}"',
209
+ "",
210
+ f"In the Reviewer {surface}, tell {reviewer_name} the same.",
211
+ ]
212
+ width = max(len(line) for line in lines) + 4
213
+ print("╔" + "═" * (width - 2) + "╗")
214
+ for line in lines:
215
+ print("║ " + line.ljust(width - 4) + " ║")
216
+ print("╚" + "═" * (width - 2) + "╝")
217
+
218
+
219
+ def quickstart_command(args: list[str]) -> int:
220
+ """Run setup + init + session start in one command."""
221
+ from tagteam.session import SUPPORTED_BACKENDS, default_backend, ensure_session
222
+ from tagteam.setup import run_setup
223
+
224
+ project_dir = "."
225
+ backend = None
226
+ i = 0
227
+ while i < len(args):
228
+ if args[i] == "--dir" and i + 1 < len(args):
229
+ project_dir = args[i + 1]
230
+ i += 2
231
+ elif args[i] == "--backend" and i + 1 < len(args):
232
+ backend = args[i + 1]
233
+ i += 2
234
+ else:
235
+ i += 1
236
+
237
+ if backend is not None and backend not in SUPPORTED_BACKENDS:
238
+ print(f"Invalid backend: {backend}. Use 'iterm2', 'tmux', or 'manual'.")
239
+ return 1
240
+
241
+ project_dir = str(Path(project_dir).resolve())
242
+
243
+ print("Tagteam - Quick Start")
244
+ print("========================")
245
+ print(f"Project: {project_dir}")
246
+ print()
247
+
248
+ print("[1/3] Framework setup...")
249
+ run_setup(project_dir)
250
+ print()
251
+
252
+ print("[2/3] Agent configuration...")
253
+ if not run_init(project_dir, show_explainer=False):
254
+ return 1
255
+ print()
256
+
257
+ print("[3/3] Starting session...")
258
+ outcome = ensure_session(project_dir, backend, launch=True)
259
+ if outcome == "error":
260
+ return 1
261
+
262
+ effective_backend = backend or default_backend()
263
+ surface = _BACKEND_SURFACE.get(effective_backend, "terminal")
264
+
265
+ config = read_config(Path(project_dir) / "tagteam.yaml") or {}
266
+ agents = config.get("agents", {})
267
+ lead_name = agents.get("lead", {}).get("name", "Lead")
268
+ reviewer_name = agents.get("reviewer", {}).get("name", "Reviewer")
269
+
270
+ print(HANDOFF_EXPLAINER)
271
+
272
+ if outcome == "exists":
273
+ print("Session already running. Switch to it to continue.")
274
+ return 0
275
+
276
+ _print_priming_box(lead_name, reviewer_name, surface)
277
+ return 0
278
+
279
+
280
+ def upgrade_command() -> int:
281
+ """Re-run setup on all registered projects."""
282
+ from tagteam.registry import get_registered_projects
283
+ from tagteam.setup import main as setup_main
284
+
285
+ projects = get_registered_projects()
286
+
287
+ if not projects:
288
+ print("No registered projects found.")
289
+ print()
290
+ print("Projects are registered automatically when you run 'tagteam setup'.")
291
+ print("Run 'tagteam setup <dir>' in each project directory first.")
292
+ return 0
293
+
294
+ print(f"Upgrading {len(projects)} registered project(s)...")
295
+ print()
296
+
297
+ failed = []
298
+ for project_dir in projects:
299
+ print("=" * 60)
300
+ print(f"Project: {project_dir}")
301
+ print("=" * 60)
302
+ try:
303
+ setup_main(project_dir)
304
+ except Exception as exc:
305
+ print(f" ERROR: {exc}")
306
+ failed.append(project_dir)
307
+ print()
308
+
309
+ if failed:
310
+ print(f"Completed with {len(failed)} error(s):")
311
+ for project_dir in failed:
312
+ print(f" - {project_dir}")
313
+ return 1
314
+
315
+ print(f"All {len(projects)} project(s) upgraded successfully.")
316
+ return 0
317
+
318
+
319
+ HELP_TEXT = """\
320
+ Tagteam
321
+
322
+ Usage: tagteam <command>
323
+
324
+ Quick start (from project root):
325
+ tagteam quickstart
326
+
327
+ This runs setup, agent configuration, and session start in one command.
328
+ The session backend is auto-detected unless you pass --backend.
329
+
330
+ Commands:
331
+ quickstart Setup + init + session start in one command
332
+ init Create tagteam.yaml configuration interactively
333
+ setup [dir] Copy framework files to a project directory
334
+ session Manage orchestration session (start/kill/attach)
335
+ watch Start the watcher daemon for automated orchestration
336
+ state View or update the orchestration state file
337
+ roadmap Query roadmap phases and build execution queue
338
+ cycle Manage cycle documents (init, add, status, rounds, render)
339
+ serve Start the web dashboard server
340
+ tui Launch the Handoff Saloon terminal UI
341
+ migrate Migrate legacy projects to use tagteam.yaml
342
+ upgrade Re-run setup on all registered projects (after pip upgrade)
343
+
344
+ Advanced setup (individual steps, from project root):
345
+ tagteam setup
346
+ tagteam init
347
+ tagteam session start
348
+
349
+ Manual workflow fallback:
350
+ tagteam session start --backend manual
351
+ tagteam watch --mode notify
352
+ """
353
+
354
+
355
+ def main() -> int:
356
+ """Main CLI entry point."""
357
+ if len(sys.argv) < 2:
358
+ print(HELP_TEXT)
359
+ return 1
360
+
361
+ command = sys.argv[1].lower()
362
+
363
+ if command == "quickstart":
364
+ return quickstart_command(sys.argv[2:])
365
+ if command == "init":
366
+ return init_command()
367
+ if command == "setup":
368
+ target = sys.argv[2] if len(sys.argv) > 2 else "."
369
+ return setup_command(target)
370
+ if command == "migrate":
371
+ from tagteam.migrate import migrate_command
372
+
373
+ return migrate_command(sys.argv[2:])
374
+ if command == "watch":
375
+ from tagteam.watcher import watch_command
376
+
377
+ return watch_command(sys.argv[2:])
378
+ if command == "roadmap":
379
+ from tagteam.roadmap import roadmap_command
380
+
381
+ return roadmap_command(sys.argv[2:])
382
+ if command == "cycle":
383
+ from tagteam.cycle import cycle_command
384
+
385
+ return cycle_command(sys.argv[2:])
386
+ if command == "state":
387
+ from tagteam.state import state_command
388
+
389
+ return state_command(sys.argv[2:])
390
+ if command == "session":
391
+ from tagteam.session import session_command
392
+
393
+ return session_command(sys.argv[2:])
394
+ if command == "serve":
395
+ from tagteam.server import serve_command
396
+
397
+ return serve_command(sys.argv[2:])
398
+ if command == "tui":
399
+ try:
400
+ from tagteam.tui import tui_command
401
+ except ImportError:
402
+ print("The TUI requires the 'textual' package.")
403
+ print("Install it with: pip install tagteam[tui]")
404
+ return 1
405
+ return tui_command(sys.argv[2:])
406
+ if command == "upgrade":
407
+ return upgrade_command()
408
+ if command in ["-h", "--help", "help"]:
409
+ print(HELP_TEXT)
410
+ return 0
411
+
412
+ print(f"Unknown command: {command}")
413
+ print("Run 'tagteam --help' for usage.")
414
+ return 1
415
+
416
+
417
+ if __name__ == "__main__":
418
+ sys.exit(main())
tagteam/config.py ADDED
@@ -0,0 +1,185 @@
1
+ """
2
+ Centralized configuration handling for Tagteam.
3
+
4
+ This module provides a single source of truth for reading and validating
5
+ tagteam.yaml configuration files.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ # PyYAML is optional
11
+ try:
12
+ import yaml
13
+ HAS_YAML = True
14
+ except ImportError:
15
+ HAS_YAML = False
16
+
17
+
18
+ def read_config(config_path: Path | str) -> dict | None:
19
+ """Read and parse tagteam.yaml.
20
+
21
+ Args:
22
+ config_path: Path to config file
23
+
24
+ Returns:
25
+ Parsed config dict, or None if file doesn't exist or is invalid
26
+ """
27
+ path = Path(config_path)
28
+ if not path.exists():
29
+ return None
30
+
31
+ try:
32
+ content = path.read_text(encoding="utf-8")
33
+ if HAS_YAML:
34
+ result = yaml.safe_load(content)
35
+ # Only return if it's a dict (not [], "foo", or other valid YAML)
36
+ return result if isinstance(result, dict) else None
37
+
38
+ # Fallback parsing without PyYAML
39
+ lead_name = None
40
+ reviewer_name = None
41
+ lead_command = None
42
+ reviewer_command = None
43
+ lines = content.split('\n')
44
+ for i, line in enumerate(lines):
45
+ if 'lead:' in line:
46
+ for j in range(i + 1, min(i + 4, len(lines))):
47
+ sub = lines[j]
48
+ if 'name:' in sub:
49
+ lead_name = sub.split('name:')[1].strip()
50
+ elif 'command:' in sub:
51
+ lead_command = sub.split('command:')[1].strip()
52
+ elif not sub.startswith(' ') and not sub.startswith('\t'):
53
+ break
54
+ elif 'reviewer:' in line:
55
+ for j in range(i + 1, min(i + 4, len(lines))):
56
+ sub = lines[j]
57
+ if 'name:' in sub:
58
+ reviewer_name = sub.split('name:')[1].strip()
59
+ elif 'command:' in sub:
60
+ reviewer_command = sub.split('command:')[1].strip()
61
+ elif not sub.startswith(' ') and not sub.startswith('\t'):
62
+ break
63
+ if lead_name and reviewer_name:
64
+ result = {'agents': {
65
+ 'lead': {'name': lead_name},
66
+ 'reviewer': {'name': reviewer_name},
67
+ }}
68
+ if lead_command:
69
+ result['agents']['lead']['command'] = lead_command
70
+ if reviewer_command:
71
+ result['agents']['reviewer']['command'] = reviewer_command
72
+ return result
73
+ except Exception:
74
+ pass
75
+ return None
76
+
77
+
78
+ def validate_config(config: dict) -> list[str]:
79
+ """Validate tagteam.yaml structure.
80
+
81
+ Args:
82
+ config: Parsed config dict
83
+
84
+ Returns:
85
+ List of error messages (empty if valid)
86
+ """
87
+ errors = []
88
+
89
+ if not isinstance(config, dict):
90
+ return ["Config must be a YAML mapping"]
91
+
92
+ agents = config.get("agents")
93
+ if not isinstance(agents, dict):
94
+ errors.append("Missing 'agents' section")
95
+ return errors
96
+
97
+ # Validate lead
98
+ lead = agents.get("lead")
99
+ if not isinstance(lead, dict) or not lead.get("name"):
100
+ errors.append("Missing or invalid 'agents.lead.name'")
101
+
102
+ # Validate reviewer
103
+ reviewer = agents.get("reviewer")
104
+ if not isinstance(reviewer, dict) or not reviewer.get("name"):
105
+ errors.append("Missing or invalid 'agents.reviewer.name'")
106
+
107
+ # Validate command if present
108
+ for role in ["lead", "reviewer"]:
109
+ agent = agents.get(role, {})
110
+ if isinstance(agent, dict):
111
+ command = agent.get("command")
112
+ if command is not None and not isinstance(command, str):
113
+ errors.append(f"'agents.{role}.command' must be a string")
114
+ elif isinstance(command, str) and not command.strip():
115
+ errors.append(f"'agents.{role}.command' is empty")
116
+
117
+ # Validate model_patterns if present
118
+ all_patterns: list[tuple[str, list[str]]] = []
119
+ for role in ["lead", "reviewer"]:
120
+ agent = agents.get(role, {})
121
+ if not isinstance(agent, dict):
122
+ continue
123
+ patterns = agent.get("model_patterns")
124
+ if patterns is not None:
125
+ if not isinstance(patterns, list):
126
+ errors.append(f"'agents.{role}.model_patterns' must be a list")
127
+ elif not all(isinstance(p, str) and p for p in patterns):
128
+ errors.append(f"'agents.{role}.model_patterns' must contain non-empty strings")
129
+ else:
130
+ all_patterns.append((role, [p.lower() for p in patterns]))
131
+
132
+ # Check for pattern overlap (error, not warning)
133
+ if len(all_patterns) == 2:
134
+ role1, patterns1 = all_patterns[0]
135
+ role2, patterns2 = all_patterns[1]
136
+ for p1 in patterns1:
137
+ for p2 in patterns2:
138
+ if p1 in p2 or p2 in p1:
139
+ errors.append(
140
+ f"Pattern overlap: '{p1}' ({role1}) and '{p2}' ({role2}) "
141
+ f"could match the same model identifier"
142
+ )
143
+
144
+ return errors
145
+
146
+
147
+ def get_launch_commands(config: dict) -> tuple[str, str]:
148
+ """Extract launch commands for lead and reviewer agents.
149
+
150
+ Uses the optional 'command' field from each agent config,
151
+ falling back to the lowercase agent name.
152
+
153
+ Args:
154
+ config: Parsed config dict
155
+
156
+ Returns:
157
+ (lead_command, reviewer_command) tuple
158
+ """
159
+ agents = config.get("agents", {})
160
+ lead = agents.get("lead", {}) if isinstance(agents.get("lead"), dict) else {}
161
+ reviewer = agents.get("reviewer", {}) if isinstance(agents.get("reviewer"), dict) else {}
162
+
163
+ lead_cmd = lead.get("command") or (lead.get("name") or "claude").lower()
164
+ reviewer_cmd = reviewer.get("command") or (reviewer.get("name") or "codex").lower()
165
+
166
+ return lead_cmd, reviewer_cmd
167
+
168
+
169
+ def get_agent_names(config: dict) -> tuple[str | None, str | None]:
170
+ """Extract lead and reviewer names from config.
171
+
172
+ Args:
173
+ config: Parsed config dict
174
+
175
+ Returns:
176
+ (lead_name, reviewer_name) tuple, with None for missing values
177
+ """
178
+ agents = config.get("agents", {})
179
+ lead = agents.get("lead", {})
180
+ reviewer = agents.get("reviewer", {})
181
+
182
+ lead_name = lead.get("name") if isinstance(lead, dict) else None
183
+ reviewer_name = reviewer.get("name") if isinstance(reviewer, dict) else None
184
+
185
+ return lead_name, reviewer_name