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.
- tagteam/__init__.py +13 -0
- tagteam/__main__.py +6 -0
- tagteam/cli.py +418 -0
- tagteam/config.py +185 -0
- tagteam/cycle.py +607 -0
- tagteam/data/.claude/skills/handoff/SKILL.md +153 -0
- tagteam/data/checklists/code_review.md +43 -0
- tagteam/data/checklists/plan_review.md +34 -0
- tagteam/data/templates/cycle.md +32 -0
- tagteam/data/templates/decision_log.md +26 -0
- tagteam/data/templates/feedback.md +42 -0
- tagteam/data/templates/handoff_impl.md +36 -0
- tagteam/data/templates/handoff_plan.md +37 -0
- tagteam/data/templates/implementation_log.md +23 -0
- tagteam/data/templates/phase_plan.md +44 -0
- tagteam/data/templates/requirements_brief.md +24 -0
- tagteam/data/templates/roadmap.md +39 -0
- tagteam/data/templates/sync_state.md +33 -0
- tagteam/data/web/app.js +1177 -0
- tagteam/data/web/conversation.js +669 -0
- tagteam/data/web/index.html +179 -0
- tagteam/data/web/sprites.js +379 -0
- tagteam/data/web/styles.css +840 -0
- tagteam/data/workflows.md +219 -0
- tagteam/iterm.py +417 -0
- tagteam/migrate.py +115 -0
- tagteam/parser.py +240 -0
- tagteam/registry.py +62 -0
- tagteam/roadmap.py +184 -0
- tagteam/server.py +540 -0
- tagteam/session.py +418 -0
- tagteam/setup.py +226 -0
- tagteam/state.py +597 -0
- tagteam/templates.py +42 -0
- tagteam/tui/__init__.py +52 -0
- tagteam/tui/__main__.py +8 -0
- tagteam/tui/app.py +513 -0
- tagteam/tui/art/__init__.py +1 -0
- tagteam/tui/art/clock.py +16 -0
- tagteam/tui/art/mayor.py +36 -0
- tagteam/tui/art/rabbit.py +33 -0
- tagteam/tui/art/saloon.py +61 -0
- tagteam/tui/characters.py +34 -0
- tagteam/tui/clock_widget.py +98 -0
- tagteam/tui/conversation.py +96 -0
- tagteam/tui/conversations/__init__.py +1 -0
- tagteam/tui/conversations/intro.py +206 -0
- tagteam/tui/conversations/transitions.py +86 -0
- tagteam/tui/dialogue.py +322 -0
- tagteam/tui/handoff_reader.py +69 -0
- tagteam/tui/map_data.py +216 -0
- tagteam/tui/map_widget.py +142 -0
- tagteam/tui/review_dialogue.py +192 -0
- tagteam/tui/review_replay.py +89 -0
- tagteam/tui/scene.py +212 -0
- tagteam/tui/sound.py +40 -0
- tagteam/tui/sounds/bell.wav +0 -0
- tagteam/tui/sounds/chime.wav +0 -0
- tagteam/tui/sounds/coo.wav +0 -0
- tagteam/tui/sounds/stamp.wav +0 -0
- tagteam/tui/sounds/tick.wav +0 -0
- tagteam/tui/state_watcher.py +116 -0
- tagteam/tui/status_bar.py +90 -0
- tagteam/watcher.py +762 -0
- tagteam-0.3.0.dist-info/METADATA +178 -0
- tagteam-0.3.0.dist-info/RECORD +69 -0
- tagteam-0.3.0.dist-info/WHEEL +5 -0
- tagteam-0.3.0.dist-info/entry_points.txt +3 -0
- 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
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
|