@team-agent/installer 0.1.0

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 (36) hide show
  1. package/README.md +201 -0
  2. package/crates/team-agent-core/Cargo.toml +12 -0
  3. package/crates/team-agent-core/src/lib.rs +287 -0
  4. package/crates/team-agent-core/src/main.rs +152 -0
  5. package/examples/team.spec.yaml +206 -0
  6. package/examples/team_state.md +35 -0
  7. package/npm/install.mjs +266 -0
  8. package/package.json +28 -0
  9. package/pyproject.toml +18 -0
  10. package/schemas/result-envelope.schema.json +76 -0
  11. package/schemas/team.schema.json +241 -0
  12. package/scripts/install.py +88 -0
  13. package/scripts/run_regression_tests.py +79 -0
  14. package/skills/team-agent/SKILL.md +173 -0
  15. package/src/team_agent/__init__.py +3 -0
  16. package/src/team_agent/__main__.py +5 -0
  17. package/src/team_agent/cli.py +857 -0
  18. package/src/team_agent/compiler.py +269 -0
  19. package/src/team_agent/coordinator.py +62 -0
  20. package/src/team_agent/errors.py +10 -0
  21. package/src/team_agent/events.py +37 -0
  22. package/src/team_agent/fake_worker.py +80 -0
  23. package/src/team_agent/mcp_server.py +579 -0
  24. package/src/team_agent/message_store.py +497 -0
  25. package/src/team_agent/paths.py +45 -0
  26. package/src/team_agent/permissions.py +123 -0
  27. package/src/team_agent/profiles.py +882 -0
  28. package/src/team_agent/providers.py +1045 -0
  29. package/src/team_agent/routing.py +84 -0
  30. package/src/team_agent/runtime.py +5213 -0
  31. package/src/team_agent/rust_core.py +156 -0
  32. package/src/team_agent/simple_yaml.py +236 -0
  33. package/src/team_agent/spec.py +308 -0
  34. package/src/team_agent/state.py +112 -0
  35. package/src/team_agent/task_graph.py +80 -0
  36. package/templates/team_state.md +32 -0
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from team_agent.errors import ValidationError
7
+ from team_agent.permissions import CANONICAL_TOOLS, expand_tools
8
+ from team_agent.profiles import AUTH_MODES
9
+ from team_agent.simple_yaml import loads
10
+ from team_agent.task_graph import find_dependency_cycle
11
+
12
+ SUPPORTED_PROVIDERS = {"claude", "claude_code", "codex", "gemini_cli", "fake"}
13
+
14
+
15
+ def load_yaml(path: Path) -> dict[str, Any]:
16
+ try:
17
+ data = loads(path.read_text(encoding="utf-8"))
18
+ except OSError as exc:
19
+ raise ValidationError(f"Cannot read {path}: {exc}") from exc
20
+ except ValueError as exc:
21
+ raise ValidationError(f"Invalid YAML in {path}: {exc}") from exc
22
+ if not isinstance(data, dict):
23
+ raise ValidationError(f"{path} must contain a YAML object")
24
+ return data
25
+
26
+
27
+ def load_spec(path: Path) -> dict[str, Any]:
28
+ spec = load_yaml(path)
29
+ validate_spec(spec, base_dir=path.parent)
30
+ return spec
31
+
32
+
33
+ def validate_spec(spec: dict[str, Any], base_dir: Path | None = None) -> None:
34
+ messages = _basic_schema_errors(spec)
35
+ messages.extend(_semantic_errors(spec, base_dir or Path.cwd()))
36
+ if messages:
37
+ joined = "\n".join(f"- {m}" for m in messages)
38
+ raise ValidationError(f"team.spec.yaml validation failed:\n{joined}")
39
+
40
+
41
+ RESULT_COLLECTION_SCHEMAS: dict[str, tuple[set[str], set[str]]] = {
42
+ "changes": ({"path", "kind", "description"}, {"path", "kind", "description"}),
43
+ "tests": ({"command", "status"}, {"command", "status", "detail"}),
44
+ "risks": ({"severity", "description"}, {"severity", "description"}),
45
+ "artifacts": ({"path", "description"}, {"path", "description"}),
46
+ "next_actions": ({"description"}, {"description"}),
47
+ }
48
+
49
+
50
+ def validate_result_envelope(envelope: dict[str, Any]) -> None:
51
+ errors = _result_schema_errors(envelope)
52
+ if errors:
53
+ joined = "\n".join(f"- {error}" for error in errors)
54
+ raise ValidationError(f"result_envelope_v1 validation failed:\n{joined}")
55
+
56
+
57
+ def _basic_schema_errors(spec: dict[str, Any]) -> list[str]:
58
+ errors: list[str] = []
59
+ root_keys = {"version", "team", "leader", "agents", "routing", "communication", "runtime", "context", "tasks"}
60
+ _check_keys(spec, "/", root_keys, root_keys, errors)
61
+ if spec.get("version") != 1:
62
+ errors.append("/version: must equal 1")
63
+ _check_keys(spec.get("team"), "/team", {"name", "mode", "objective", "workspace"}, {"name", "mode", "objective", "workspace"}, errors)
64
+ if spec.get("team", {}).get("mode") not in {"supervisor_worker", "swarm_limited"}:
65
+ errors.append("/team/mode: invalid mode")
66
+ _check_keys(
67
+ spec.get("leader"),
68
+ "/leader",
69
+ {"id", "role", "provider", "model", "tools", "context_policy"},
70
+ {"id", "role", "provider", "model", "tools", "context_policy"},
71
+ errors,
72
+ )
73
+ _check_context_policy(spec.get("leader", {}).get("context_policy"), errors)
74
+ if not isinstance(spec.get("agents"), list) or not spec.get("agents"):
75
+ errors.append("/agents: must be a non-empty list")
76
+ else:
77
+ for idx, agent in enumerate(spec["agents"]):
78
+ _check_agent(agent, f"/agents/{idx}", errors)
79
+ _check_routing(spec.get("routing"), errors)
80
+ _check_communication(spec.get("communication"), errors)
81
+ _check_runtime(spec.get("runtime"), errors)
82
+ _check_context(spec.get("context"), errors)
83
+ if not isinstance(spec.get("tasks"), list):
84
+ errors.append("/tasks: must be a list")
85
+ else:
86
+ for idx, task in enumerate(spec["tasks"]):
87
+ _check_task(task, f"/tasks/{idx}", errors)
88
+ return errors
89
+
90
+
91
+ def _result_schema_errors(envelope: Any) -> list[str]:
92
+ errors: list[str] = []
93
+ required = {"schema_version", "task_id", "agent_id", "status", "summary", "changes", "tests", "risks", "artifacts", "next_actions"}
94
+ _check_keys(envelope, "/", required, required, errors)
95
+ if not isinstance(envelope, dict):
96
+ return errors
97
+ if envelope.get("schema_version") != "result_envelope_v1":
98
+ errors.append("/schema_version: must be result_envelope_v1")
99
+ for field in ["task_id", "agent_id", "summary"]:
100
+ if field in envelope and not isinstance(envelope[field], str):
101
+ errors.append(f"/{field}: must be a string")
102
+ elif field in envelope and not envelope[field]:
103
+ errors.append(f"/{field}: must not be empty")
104
+ if envelope.get("status") not in {"success", "blocked", "failed", "partial"}:
105
+ errors.append("/status: invalid result status")
106
+ if "schema" in envelope:
107
+ errors.append("/schema: use schema_version, not schema")
108
+ for field, (item_required, item_allowed) in RESULT_COLLECTION_SCHEMAS.items():
109
+ if field not in envelope:
110
+ continue
111
+ value = envelope[field]
112
+ if not isinstance(value, list):
113
+ errors.append(f"/{field}: must be a list")
114
+ continue
115
+ for idx, item in enumerate(value):
116
+ item_path = f"/{field}/{idx}"
117
+ _check_keys(item, item_path, item_required, item_allowed, errors)
118
+ if not isinstance(item, dict):
119
+ continue
120
+ if field == "changes" and item.get("kind") not in {"created", "modified", "deleted", "observed"}:
121
+ errors.append(f"{item_path}/kind: invalid change kind")
122
+ if field == "tests" and item.get("status") not in {"passed", "failed", "not_run", "skipped"}:
123
+ errors.append(f"{item_path}/status: invalid test status")
124
+ if field == "risks" and item.get("severity") not in {"low", "medium", "high"}:
125
+ errors.append(f"{item_path}/severity: invalid risk severity")
126
+ for key, child in item.items():
127
+ if key in item_allowed and not isinstance(child, str):
128
+ errors.append(f"{item_path}/{key}: must be a string")
129
+ return errors
130
+
131
+
132
+ def _check_agent(agent: Any, path: str, errors: list[str]) -> None:
133
+ required = {"id", "role", "provider", "model", "working_directory", "system_prompt", "tools", "permission_mode", "preferred_for", "avoid_for", "output_contract"}
134
+ allowed = required | {"paused", "auth_mode", "profile", "credential_ref"}
135
+ _check_keys(agent, path, required, allowed, errors)
136
+ if not isinstance(agent, dict):
137
+ return
138
+ _check_keys(agent.get("system_prompt"), f"{path}/system_prompt", {"inline", "file"}, {"inline", "file"}, errors)
139
+ _check_list(agent.get("tools"), f"{path}/tools", errors)
140
+ _check_list(agent.get("preferred_for"), f"{path}/preferred_for", errors)
141
+ _check_list(agent.get("avoid_for"), f"{path}/avoid_for", errors)
142
+ _check_keys(agent.get("output_contract"), f"{path}/output_contract", {"format", "required_fields"}, {"format", "required_fields"}, errors)
143
+ if agent.get("output_contract", {}).get("format") != "result_envelope_v1":
144
+ errors.append(f"{path}/output_contract/format: must be result_envelope_v1")
145
+
146
+
147
+ def _check_context_policy(policy: Any, errors: list[str]) -> None:
148
+ _check_keys(
149
+ policy,
150
+ "/leader/context_policy",
151
+ {"keep_user_thread", "receive_worker_outputs", "max_worker_result_tokens"},
152
+ {"keep_user_thread", "receive_worker_outputs", "max_worker_result_tokens"},
153
+ errors,
154
+ )
155
+
156
+
157
+ def _check_routing(routing: Any, errors: list[str]) -> None:
158
+ _check_keys(routing, "/routing", {"default_assignee", "rules"}, {"default_assignee", "rules"}, errors)
159
+ if not isinstance(routing, dict):
160
+ return
161
+ if not isinstance(routing.get("rules"), list):
162
+ errors.append("/routing/rules: must be a list")
163
+ return
164
+ for idx, rule in enumerate(routing["rules"]):
165
+ allowed = {"id", "when", "match", "assign_to", "priority"}
166
+ required = {"id", "assign_to", "priority"}
167
+ _check_keys(rule, f"/routing/rules/{idx}", required, allowed, errors)
168
+ if isinstance(rule, dict) and not (rule.get("when") or rule.get("match")):
169
+ errors.append(f"/routing/rules/{idx}: must include when or match")
170
+
171
+
172
+ def _check_communication(comm: Any, errors: list[str]) -> None:
173
+ required = {"protocol", "topology", "worker_to_worker", "ack_timeout_sec", "result_format", "message_store"}
174
+ _check_keys(comm, "/communication", required, required, errors)
175
+ if not isinstance(comm, dict):
176
+ return
177
+ if comm.get("protocol") not in {"mcp_inbox", "file_bus"}:
178
+ errors.append("/communication/protocol: invalid protocol")
179
+ if comm.get("result_format") != "result_envelope_v1":
180
+ errors.append("/communication/result_format: must be result_envelope_v1")
181
+ _check_keys(comm.get("message_store"), "/communication/message_store", {"sqlite", "mirror_files"}, {"sqlite", "mirror_files"}, errors)
182
+
183
+
184
+ def _check_runtime(runtime: Any, errors: list[str]) -> None:
185
+ required = {"backend", "display_backend", "session_name", "auto_launch", "require_user_approval_before_launch", "max_active_agents", "startup_order"}
186
+ allowed = required | {
187
+ "dangerous_auto_approve",
188
+ "auto_attach_leader",
189
+ "fast",
190
+ "tick_interval_sec",
191
+ "push_min_interval_sec",
192
+ "stuck_timeout_sec",
193
+ }
194
+ _check_keys(runtime, "/runtime", required, allowed, errors)
195
+ if not isinstance(runtime, dict):
196
+ return
197
+ if runtime.get("backend") not in {"tmux", "pty"}:
198
+ errors.append("/runtime/backend: invalid backend")
199
+ if runtime.get("display_backend") not in {"none", "tmux_attach", "iterm", "ghostty", "ghostty_window"}:
200
+ errors.append("/runtime/display_backend: invalid display backend")
201
+ if "dangerous_auto_approve" in runtime and not isinstance(runtime["dangerous_auto_approve"], bool):
202
+ errors.append("/runtime/dangerous_auto_approve: must be a boolean")
203
+ _check_list(runtime.get("startup_order"), "/runtime/startup_order", errors)
204
+
205
+
206
+ def _check_context(context: Any, errors: list[str]) -> None:
207
+ required = {"state_file", "artifact_dir", "log_dir", "summarization"}
208
+ _check_keys(context, "/context", required, required, errors)
209
+ if isinstance(context, dict):
210
+ _check_keys(context.get("summarization"), "/context/summarization", {"worker_full_logs", "state_update"}, {"worker_full_logs", "state_update"}, errors)
211
+
212
+
213
+ def _check_task(task: Any, path: str, errors: list[str]) -> None:
214
+ required = {"id", "title", "type", "assignee", "deps", "acceptance", "status"}
215
+ allowed = required | {"description", "requires_tools", "files", "risk", "retry_limit", "human_confirmation"}
216
+ _check_keys(task, path, required, allowed, errors)
217
+ if not isinstance(task, dict):
218
+ return
219
+ _check_list(task.get("deps"), f"{path}/deps", errors)
220
+ _check_list(task.get("acceptance"), f"{path}/acceptance", errors)
221
+ if task.get("status") not in {"pending", "ready", "running", "blocked", "needs_retry", "done", "failed", "cancelled"}:
222
+ errors.append(f"{path}/status: invalid task status")
223
+
224
+
225
+ def _check_keys(obj: Any, path: str, required: set[str], allowed: set[str], errors: list[str]) -> None:
226
+ if not isinstance(obj, dict):
227
+ errors.append(f"{path}: must be an object")
228
+ return
229
+ missing = sorted(required - set(obj))
230
+ for key in missing:
231
+ errors.append(f"{path.rstrip('/')}/{key}: missing required field")
232
+ unknown = sorted(set(obj) - allowed)
233
+ for key in unknown:
234
+ errors.append(f"{path.rstrip('/')}/{key}: unknown field")
235
+
236
+
237
+ def _check_list(value: Any, path: str, errors: list[str]) -> None:
238
+ if not isinstance(value, list):
239
+ errors.append(f"{path}: must be a list")
240
+
241
+
242
+ def _semantic_errors(spec: dict[str, Any], base_dir: Path) -> list[str]:
243
+ errors: list[str] = []
244
+ leader = spec.get("leader", {})
245
+ agents = spec.get("agents", [])
246
+ agent_ids = {a.get("id") for a in agents if isinstance(a, dict)}
247
+ all_ids = set(agent_ids)
248
+ if leader.get("id"):
249
+ all_ids.add(leader["id"])
250
+
251
+ for path, provider in [("/leader/provider", leader.get("provider"))]:
252
+ if provider not in SUPPORTED_PROVIDERS:
253
+ errors.append(f"{path}: unknown provider {provider!r}")
254
+ for idx, agent in enumerate(agents):
255
+ provider = agent.get("provider")
256
+ if provider not in SUPPORTED_PROVIDERS:
257
+ errors.append(f"/agents/{idx}/provider: unknown provider {provider!r}")
258
+ auth_mode = agent.get("auth_mode")
259
+ if auth_mode is not None and auth_mode not in AUTH_MODES:
260
+ errors.append(f"/agents/{idx}/auth_mode: unknown auth_mode {auth_mode!r}")
261
+ prompt_file = agent.get("system_prompt", {}).get("file")
262
+ if prompt_file:
263
+ candidate = Path(prompt_file)
264
+ if not candidate.is_absolute():
265
+ candidate = base_dir / candidate
266
+ if not candidate.exists():
267
+ errors.append(f"/agents/{idx}/system_prompt/file: file not found: {candidate}")
268
+ for tool in expand_tools(agent.get("tools", [])):
269
+ if tool not in CANONICAL_TOOLS:
270
+ errors.append(f"/agents/{idx}/tools: unknown tool {tool!r}")
271
+
272
+ leader_tools = leader.get("tools", [])
273
+ for tool in expand_tools(leader_tools):
274
+ if tool not in CANONICAL_TOOLS:
275
+ errors.append(f"/leader/tools: unknown tool {tool!r}")
276
+
277
+ routing = spec.get("routing", {})
278
+ default_assignee = routing.get("default_assignee")
279
+ if default_assignee and default_assignee not in all_ids:
280
+ errors.append(f"/routing/default_assignee: unknown agent {default_assignee!r}")
281
+ for idx, rule in enumerate(routing.get("rules", [])):
282
+ target = rule.get("assign_to")
283
+ if target not in all_ids:
284
+ errors.append(f"/routing/rules/{idx}/assign_to: unknown agent {target!r}")
285
+
286
+ tasks = spec.get("tasks", [])
287
+ task_ids = {t.get("id") for t in tasks if isinstance(t, dict)}
288
+ for idx, task in enumerate(tasks):
289
+ assignee = task.get("assignee")
290
+ if assignee and assignee not in all_ids:
291
+ errors.append(f"/tasks/{idx}/assignee: unknown agent {assignee!r}")
292
+ for dep in task.get("deps", []):
293
+ if dep not in task_ids:
294
+ errors.append(f"/tasks/{idx}/deps: unknown dependency {dep!r}")
295
+
296
+ cycle = find_dependency_cycle(tasks)
297
+ if cycle:
298
+ errors.append(f"/tasks: dependency cycle detected: {' -> '.join(cycle)}")
299
+ return errors
300
+
301
+
302
+ def workspace_from_spec(spec: dict[str, Any], spec_path: Path | None = None) -> Path:
303
+ raw = spec.get("team", {}).get("workspace") or "."
304
+ path = Path(raw)
305
+ if path.is_absolute():
306
+ return path
307
+ base = spec_path.parent if spec_path else Path.cwd()
308
+ return (base / path).resolve()
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from team_agent.paths import runtime_dir
10
+ from team_agent.simple_yaml import dumps
11
+
12
+
13
+ SESSION_STATE_FIELDS = [
14
+ "session_id",
15
+ "rollout_path",
16
+ "captured_at",
17
+ "captured_via",
18
+ "attribution_confidence",
19
+ "spawn_cwd",
20
+ ]
21
+
22
+
23
+ def runtime_state_path(workspace: Path) -> Path:
24
+ return runtime_dir(workspace) / "state.json"
25
+
26
+
27
+ def load_runtime_state(workspace: Path) -> dict[str, Any]:
28
+ path = runtime_state_path(workspace)
29
+ if not path.exists():
30
+ return {"agents": {}, "tasks": [], "session_name": None}
31
+ state = json.loads(path.read_text(encoding="utf-8"))
32
+ for agent_state in state.get("agents", {}).values():
33
+ if isinstance(agent_state, dict):
34
+ for field in SESSION_STATE_FIELDS:
35
+ agent_state.setdefault(field, None)
36
+ return state
37
+
38
+
39
+ def save_runtime_state(workspace: Path, state: dict[str, Any]) -> None:
40
+ path = runtime_state_path(workspace)
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
43
+ tmp_path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
44
+ os.replace(tmp_path, path)
45
+
46
+
47
+ def write_team_state(workspace: Path, spec: dict[str, Any], runtime: dict[str, Any], results: list[dict[str, Any]] | None = None) -> Path:
48
+ path = workspace / spec.get("context", {}).get("state_file", "team_state.md")
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ lines = [
51
+ "# Team State",
52
+ "",
53
+ f"Updated: {datetime.now(timezone.utc).isoformat()}",
54
+ "",
55
+ "## Objective",
56
+ "",
57
+ spec.get("team", {}).get("objective", ""),
58
+ "",
59
+ "## Team",
60
+ "",
61
+ f"- Name: {spec.get('team', {}).get('name')}",
62
+ f"- Runtime session: {runtime.get('session_name')}",
63
+ ]
64
+ receiver = runtime.get("leader_receiver") or {}
65
+ if receiver:
66
+ if receiver.get("mode") == "direct_tmux":
67
+ lines.append(
68
+ f"- Leader receiver: direct tmux {receiver.get('pane_id')} "
69
+ f"({receiver.get('provider')}, {receiver.get('status')})"
70
+ )
71
+ else:
72
+ lines.append(f"- Leader inbox fallback: {receiver.get('session')}:{receiver.get('window')} ({receiver.get('status')})")
73
+ lines.append(f"- Leader inbox log: {receiver.get('path')}")
74
+ lines.extend(["", "## Agents", ""])
75
+ for agent in spec.get("agents", []):
76
+ status = runtime.get("agents", {}).get(agent["id"], {}).get("status", "unknown")
77
+ lines.append(f"- {agent['id']}: {agent['role']} on {agent['provider']} ({status})")
78
+ lines.extend(["", "## Task Graph", ""])
79
+ for task in runtime.get("tasks", spec.get("tasks", [])):
80
+ deps = ", ".join(task.get("deps", [])) or "none"
81
+ assignee = task.get("assignee") or "unassigned"
82
+ lines.append(f"- {task['id']} [{task.get('status', 'pending')}], assignee={assignee}, deps={deps}: {task['title']}")
83
+ if task.get("last_result_summary"):
84
+ lines.append(f" Summary: {task['last_result_summary']}")
85
+ if task.get("artifact_refs"):
86
+ for ref in task["artifact_refs"]:
87
+ if isinstance(ref, dict):
88
+ lines.append(f" Artifact: {ref.get('path')} - {ref.get('description', '')}")
89
+ else:
90
+ lines.append(f" Artifact: INVALID artifact ref {ref!r}")
91
+ lines.extend(["", "## Latest Results", ""])
92
+ for result in results or []:
93
+ envelope = json.loads(result["envelope"]) if isinstance(result.get("envelope"), str) else result
94
+ lines.append(f"- {envelope.get('task_id')} from {envelope.get('agent_id')}: {envelope.get('status')} - {envelope.get('summary')}")
95
+ lines.extend(["", "## Blockers", ""])
96
+ blockers = [
97
+ task
98
+ for task in runtime.get("tasks", spec.get("tasks", []))
99
+ if task.get("status") in {"blocked", "failed", "needs_retry"}
100
+ ]
101
+ if blockers:
102
+ for task in blockers:
103
+ lines.append(f"- {task['id']}: {task.get('last_result_summary', task.get('title'))}")
104
+ else:
105
+ lines.append("- None")
106
+ lines.extend(["", "## Next Step", "", "- Continue routing ready tasks and collect result envelopes."])
107
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
108
+ return path
109
+
110
+
111
+ def write_spec(path: Path, spec: dict[str, Any]) -> None:
112
+ path.write_text(dumps(spec), encoding="utf-8")
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ TASK_STATUSES = {
6
+ "pending",
7
+ "ready",
8
+ "running",
9
+ "blocked",
10
+ "needs_retry",
11
+ "done",
12
+ "failed",
13
+ "cancelled",
14
+ }
15
+
16
+ TERMINAL_TASK_STATUSES = {"done", "failed", "cancelled"}
17
+
18
+
19
+ def find_dependency_cycle(tasks: list[dict[str, Any]]) -> list[str]:
20
+ graph = {t.get("id"): list(t.get("deps", [])) for t in tasks if t.get("id")}
21
+ visiting: set[str] = set()
22
+ visited: set[str] = set()
23
+ stack: list[str] = []
24
+
25
+ def visit(node: str) -> list[str] | None:
26
+ if node in visited:
27
+ return None
28
+ if node in visiting:
29
+ if node in stack:
30
+ return stack[stack.index(node) :] + [node]
31
+ return [node, node]
32
+ visiting.add(node)
33
+ stack.append(node)
34
+ for dep in graph.get(node, []):
35
+ if dep in graph:
36
+ cycle = visit(dep)
37
+ if cycle:
38
+ return cycle
39
+ stack.pop()
40
+ visiting.remove(node)
41
+ visited.add(node)
42
+ return None
43
+
44
+ for node in graph:
45
+ cycle = visit(node)
46
+ if cycle:
47
+ return cycle
48
+ return []
49
+
50
+
51
+ def ready_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
52
+ by_id = {t["id"]: t for t in tasks}
53
+ ready: list[dict[str, Any]] = []
54
+ for task in tasks:
55
+ if task.get("status", "pending") not in {"pending", "ready", "needs_retry"}:
56
+ continue
57
+ deps_done = all(by_id.get(dep, {}).get("status") == "done" for dep in task.get("deps", []))
58
+ if deps_done:
59
+ ready.append(task)
60
+ return ready
61
+
62
+
63
+ def update_task_status(
64
+ tasks: list[dict[str, Any]],
65
+ task_id: str,
66
+ status: str,
67
+ summary: str | None = None,
68
+ artifact_refs: list[dict[str, Any]] | None = None,
69
+ ) -> None:
70
+ if status not in TASK_STATUSES:
71
+ raise ValueError(f"Unknown task status: {status}")
72
+ for task in tasks:
73
+ if task.get("id") == task_id:
74
+ task["status"] = status
75
+ if summary is not None:
76
+ task["last_result_summary"] = summary
77
+ if artifact_refs is not None:
78
+ task["artifact_refs"] = artifact_refs
79
+ return
80
+ raise KeyError(f"Unknown task id: {task_id}")
@@ -0,0 +1,32 @@
1
+ # Team State
2
+
3
+ Updated: not launched
4
+
5
+ ## Objective
6
+
7
+ Pending.
8
+
9
+ ## Team
10
+
11
+ - Name: pending
12
+ - Runtime session: pending
13
+
14
+ ## Agents
15
+
16
+ - Pending launch.
17
+
18
+ ## Task Graph
19
+
20
+ - Pending task graph.
21
+
22
+ ## Latest Results
23
+
24
+ - None.
25
+
26
+ ## Blockers
27
+
28
+ - None.
29
+
30
+ ## Next Step
31
+
32
+ - Run `team-agent validate team.spec.yaml`, review permissions, then run `team-agent launch team.spec.yaml --yes`.