taskboy 0.1.1__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 (95) hide show
  1. taskboy/__init__.py +0 -0
  2. taskboy/adapters/__init__.py +0 -0
  3. taskboy/adapters/_util.py +30 -0
  4. taskboy/adapters/aws_read.py +107 -0
  5. taskboy/adapters/confluence.py +88 -0
  6. taskboy/adapters/github_api.py +564 -0
  7. taskboy/adapters/issues.py +435 -0
  8. taskboy/adapters/jira.py +263 -0
  9. taskboy/adapters/sentry.py +82 -0
  10. taskboy/adapters/slack_history.py +204 -0
  11. taskboy/assets.py +27 -0
  12. taskboy/audit.py +78 -0
  13. taskboy/broker.py +266 -0
  14. taskboy/classifier.py +219 -0
  15. taskboy/cli.py +161 -0
  16. taskboy/config.py +539 -0
  17. taskboy/dashboard/__init__.py +5 -0
  18. taskboy/dashboard/api.py +963 -0
  19. taskboy/dashboard/app.py +78 -0
  20. taskboy/dashboard/auth.py +117 -0
  21. taskboy/dashboard/editors.py +140 -0
  22. taskboy/dashboard/gitops.py +60 -0
  23. taskboy/dashboard/render.py +48 -0
  24. taskboy/debug_feed.py +170 -0
  25. taskboy/deploy/env.example +17 -0
  26. taskboy/deploy/git-cred-helper.py +36 -0
  27. taskboy/deploy/install.sh +101 -0
  28. taskboy/deploy/remote-update.sh +52 -0
  29. taskboy/deploy/taskboy-restart.path +10 -0
  30. taskboy/deploy/taskboy-restart.service +8 -0
  31. taskboy/deploy/taskboy.service +22 -0
  32. taskboy/hooks.py +208 -0
  33. taskboy/issue_runs.py +203 -0
  34. taskboy/llm.py +125 -0
  35. taskboy/main.py +269 -0
  36. taskboy/memory.py +59 -0
  37. taskboy/models.py +103 -0
  38. taskboy/mrkdwn.py +19 -0
  39. taskboy/notify.py +41 -0
  40. taskboy/orchestrator.py +401 -0
  41. taskboy/personality.py +17 -0
  42. taskboy/prompts.py +288 -0
  43. taskboy/quick.py +234 -0
  44. taskboy/redact.py +49 -0
  45. taskboy/repocache.py +117 -0
  46. taskboy/review_requests.py +388 -0
  47. taskboy/router.py +146 -0
  48. taskboy/runner.py +581 -0
  49. taskboy/scheduler.py +301 -0
  50. taskboy/secrets.py +83 -0
  51. taskboy/settings.py +19 -0
  52. taskboy/setup_checks.py +184 -0
  53. taskboy/setup_wizard.py +699 -0
  54. taskboy/skills.py +95 -0
  55. taskboy/slack.py +666 -0
  56. taskboy/slack_users.py +32 -0
  57. taskboy/started_messages.py +33 -0
  58. taskboy/store.py +1393 -0
  59. taskboy/task_actions.py +86 -0
  60. taskboy/templates/config.example.yaml +149 -0
  61. taskboy/templates/conventions.md +60 -0
  62. taskboy/templates/help.example.md +29 -0
  63. taskboy/templates/personality_agent.example.md +5 -0
  64. taskboy/templates/personality_reviewer.example.md +5 -0
  65. taskboy/templates/services/aws.yaml +7 -0
  66. taskboy/templates/services/confluence.yaml +4 -0
  67. taskboy/templates/services/github.yaml +14 -0
  68. taskboy/templates/services/jira.yaml +6 -0
  69. taskboy/templates/services/sentry.yaml +4 -0
  70. taskboy/templates/services/slack.yaml +9 -0
  71. taskboy/templates/skills/README.md +33 -0
  72. taskboy/templates/skills/discoverissues/SKILL.md +46 -0
  73. taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
  74. taskboy/templates/skills/jira2pr/SKILL.md +66 -0
  75. taskboy/templates/skills/monitor/SKILL.md +32 -0
  76. taskboy/templates/skills/monitornew/SKILL.md +27 -0
  77. taskboy/templates/skills/refineissue/SKILL.md +35 -0
  78. taskboy/templates/skills/release/SKILL.md +36 -0
  79. taskboy/templates/skills/review/SKILL.md +89 -0
  80. taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
  81. taskboy/templates/skills/reviews/SKILL.md +26 -0
  82. taskboy/templates/skills/slack2jira/SKILL.md +47 -0
  83. taskboy/templates/skills/slack2pr/SKILL.md +64 -0
  84. taskboy/templates/skills/spec2pr/SKILL.md +35 -0
  85. taskboy/templates/slack_app_manifest.yaml +48 -0
  86. taskboy/templates/task_started_messages.yaml +14 -0
  87. taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
  88. taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
  89. taskboy/ui_dist/index.html +15 -0
  90. taskboy/workspace.py +86 -0
  91. taskboy-0.1.1.dist-info/METADATA +120 -0
  92. taskboy-0.1.1.dist-info/RECORD +95 -0
  93. taskboy-0.1.1.dist-info/WHEEL +5 -0
  94. taskboy-0.1.1.dist-info/entry_points.txt +2 -0
  95. taskboy-0.1.1.dist-info/top_level.txt +1 -0
taskboy/config.py ADDED
@@ -0,0 +1,539 @@
1
+ """config.yaml loading and validation. invalid config fails fast (exit 64) so systemd stops instead of crash-looping."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
7
+
8
+ import yaml
9
+
10
+ from taskboy.models import EFFORT_LEVELS
11
+
12
+
13
+ class ConfigError(Exception):
14
+ pass
15
+
16
+
17
+ # connected services an operator can turn on/off, each configurable in its own services/<name>.yaml file
18
+ KNOWN_SERVICES = ("slack", "github", "jira", "confluence", "sentry", "aws")
19
+
20
+
21
+ @dataclass
22
+ class SlackConfig:
23
+ team_id: str
24
+ allowed_channels: list[str] # empty = any channel the bot is invited to (the invite is the gate)
25
+ enabled: bool = False
26
+ ack_reaction: bool = True
27
+ debug_channel: str = ""
28
+ task_started_messages_path: str | None = None
29
+
30
+
31
+ @dataclass
32
+ class ReviewerConfig:
33
+ # optional second github-only persona that reviews the main agent's prs (needs its own github app)
34
+ enabled: bool = False
35
+ name: str = "Reviewer"
36
+ personality_path: str | None = None
37
+ review_agent_prs: bool = True
38
+ commit_name: str = "Reviewer"
39
+ commit_email: str = ""
40
+
41
+
42
+ @dataclass
43
+ class DashboardConfig:
44
+ # mission control web ui. served behind an alb that authenticates auth0 sso; the app
45
+ # trusts the alb's signed identity header, so bind stays private (alb -> instance only).
46
+ enabled: bool = False
47
+ bind: str = "127.0.0.1" # 0.0.0.0 on the host (security group limits ingress to the alb)
48
+ port: int = 8787
49
+ allowed_email_domain: str = "" # any member of this email domain can view; required when enabled
50
+ admin_emails: list[str] = field(default_factory=list) # only these emails can edit/cancel/retry
51
+ dev_user_email: str | None = None # local-only identity when no alb header is present
52
+ expected_alb_arn: str = "" # oidc header's "signer" claim must match this; empty fails closed (no real alb token has an empty signer)
53
+ public_url: str = "" # e.g. https://agent.example.com; empty disables dashboard links in slack posts
54
+ commit_repo: str = "" # e.g. example-org/taskboy; empty disables auto-commit of edits
55
+ commit_branch: str = "main"
56
+ committer_name: str = "" # git committer identity for dashboard auto-commits; defaults to "<agent name> Dashboard"
57
+ committer_email: str = "" # required when auto_commit.repo is set
58
+
59
+ @property
60
+ def auto_commit_enabled(self) -> bool:
61
+ return bool(self.commit_repo)
62
+
63
+
64
+ @dataclass
65
+ class CliUpdateConfig:
66
+ # off-peak, in-process upgrade of the claude-agent-sdk package (which bundles the claude cli) in the
67
+ # service venv, plus a restart so the new version takes effect — ungated by CI (issue #67)
68
+ enabled: bool = False
69
+ at_time: str = "02:00"
70
+ tzname: str = "America/Los_Angeles"
71
+
72
+
73
+ @dataclass
74
+ class Role:
75
+ name: str
76
+ members: list[str]
77
+ allowed_profiles: list[str]
78
+ model_override: bool
79
+ max_budget_usd: float | None
80
+ repos: list[str] | None
81
+
82
+
83
+ @dataclass
84
+ class Config:
85
+ max_concurrency: int
86
+ queue_max: int
87
+ max_retries: int
88
+ progress_min_interval_seconds: int
89
+ runner: str # echo (dev/lifecycle testing) | claude (real sub-agent sessions)
90
+ slack: SlackConfig
91
+ roles: dict[str, Role]
92
+ raw: dict # full parsed yaml; later phases read their own sections from here
93
+ agent_name: str = "Agent" # the main agent's display name, used in all user-facing text
94
+ conventions_path: str | None = None
95
+ personality_path: str | None = None
96
+ help_path: str | None = None
97
+ dashboard: DashboardConfig = field(default_factory=DashboardConfig)
98
+ reviewer: ReviewerConfig = field(default_factory=ReviewerConfig)
99
+ cli_update: CliUpdateConfig = field(default_factory=CliUpdateConfig)
100
+ services: dict[str, bool] = field(default_factory=dict) # KNOWN_SERVICES name -> enabled
101
+
102
+ def service_enabled(self, name: str) -> bool:
103
+ return bool(self.services.get(name, False))
104
+
105
+ def enabled_integrations(self) -> list[str]:
106
+ # the classifier's integration vocabulary (prompts.CLASSIFICATION_SCHEMA), narrowed to services that are on
107
+ return [name for name in ("github", "aws", "sentry", "jira", "confluence") if self.service_enabled(name)]
108
+
109
+
110
+ def load_config(path: str) -> Config:
111
+ try:
112
+ with open(path) as f:
113
+ raw = yaml.safe_load(f)
114
+ except FileNotFoundError:
115
+ raise ConfigError(f"config file not found: {path} (run `taskboy setup` — or `taskboy setup --local` — to create it)")
116
+ except yaml.YAMLError as e:
117
+ raise ConfigError(f"config is not valid yaml: {e}")
118
+ if not isinstance(raw, dict):
119
+ raise ConfigError("config must be a yaml mapping")
120
+ orchestrator = raw.get("orchestrator")
121
+ if not isinstance(orchestrator, dict):
122
+ raise ConfigError("config is missing the 'orchestrator' section")
123
+ runner = orchestrator.get("runner", "echo")
124
+ if runner not in ("echo", "claude"):
125
+ raise ConfigError(f"orchestrator.runner must be 'echo' or 'claude', got {runner!r}")
126
+ _merge_service_files(raw, path)
127
+ services = _services_config(raw)
128
+ slack = _slack_config(raw.get("slack") or {}, path, enabled=services["slack"])
129
+ github = raw.get("github") or {}
130
+ if not isinstance(github, dict):
131
+ raise ConfigError("github section must be a mapping")
132
+ self_repo = github.get("self_repo", "")
133
+ if not isinstance(self_repo, str):
134
+ raise ConfigError(f"github.self_repo must be a string, got {self_repo!r}")
135
+ if self_repo and self_repo not in (github.get("approved_repos") or []):
136
+ raise ConfigError(f"github.self_repo must be listed in github.approved_repos, got {self_repo!r}")
137
+ _validate_profiles(raw)
138
+ roles = _roles_config(raw, slack)
139
+ _validate_skills(raw)
140
+ agent_name, personality_path = _agent_config(raw, path)
141
+ conventions_path = _file_path(raw, path, "conventions")
142
+ help_path = _file_path(raw, path, "help")
143
+ dashboard = _dashboard_config(raw.get("dashboard"), agent_name)
144
+ reviewer = _reviewer_config(raw.get("reviewer"), path)
145
+ cli_update = _cli_update_config(raw.get("cli_update"))
146
+ return Config(
147
+ max_concurrency=_int_at_least(orchestrator, "max_concurrency", 1),
148
+ queue_max=_int_at_least(orchestrator, "queue_max", 1),
149
+ max_retries=_int_at_least(orchestrator, "max_retries", 0),
150
+ progress_min_interval_seconds=_int_at_least(orchestrator, "progress_min_interval_seconds", 0),
151
+ runner=runner,
152
+ slack=slack,
153
+ roles=roles,
154
+ raw=raw,
155
+ agent_name=agent_name,
156
+ conventions_path=conventions_path,
157
+ personality_path=personality_path,
158
+ help_path=help_path,
159
+ dashboard=dashboard,
160
+ reviewer=reviewer,
161
+ cli_update=cli_update,
162
+ services=services,
163
+ )
164
+
165
+
166
+ def _merge_service_files(raw: dict, config_path: str) -> None:
167
+ """fold services/<name>.yaml files (next to config.yaml) into their top-level sections."""
168
+ services_dir = Path(config_path).resolve().parent / "services"
169
+ if not services_dir.is_dir():
170
+ return
171
+ for file in sorted(services_dir.glob("*.yaml")):
172
+ if file.name.startswith("."):
173
+ continue # editor/os droppings must not brick a restart
174
+ name = file.stem
175
+ if name not in KNOWN_SERVICES:
176
+ raise ConfigError(f"services/{file.name} is not a known service config (known services: {', '.join(KNOWN_SERVICES)})")
177
+ if name in raw:
178
+ raise ConfigError(f"{name} is configured in both config.yaml and services/{file.name} — keep only services/{file.name}")
179
+ try:
180
+ section = yaml.safe_load(file.read_text())
181
+ except OSError as e:
182
+ raise ConfigError(f"services/{file.name} could not be read: {e}")
183
+ except yaml.YAMLError as e:
184
+ raise ConfigError(f"services/{file.name} is not valid yaml: {e}")
185
+ if not isinstance(section, dict):
186
+ raise ConfigError(f"services/{file.name} must be a yaml mapping")
187
+ if not isinstance(section.get("enabled"), bool):
188
+ raise ConfigError(f"services/{file.name} must set enabled: true or false")
189
+ raw[name] = section
190
+
191
+
192
+ def service_enabled_in(raw: dict, name: str) -> bool:
193
+ """resolved enabled flag for one service section: the explicit key, else legacy inference."""
194
+ section = raw.get(name) or {}
195
+ enabled = section.get("enabled") if isinstance(section, dict) else None
196
+ if isinstance(enabled, bool):
197
+ return enabled
198
+ return _legacy_enabled(name, section if isinstance(section, dict) else {})
199
+
200
+
201
+ def _legacy_enabled(name: str, section: dict) -> bool:
202
+ # configs from before the enabled flag signal "on" by filling in a key; github was gated by secrets alone
203
+ if name == "slack":
204
+ return bool(section.get("team_id"))
205
+ if name in ("jira", "confluence"):
206
+ return bool(section.get("site"))
207
+ if name == "sentry":
208
+ return bool(section.get("organization"))
209
+ if name == "aws":
210
+ return bool(section.get("allowed_services"))
211
+ return name == "github"
212
+
213
+
214
+ def _services_config(raw: dict) -> dict[str, bool]:
215
+ services: dict[str, bool] = {}
216
+ for name in KNOWN_SERVICES:
217
+ section = raw.get(name)
218
+ if section is None:
219
+ section = {}
220
+ if not isinstance(section, dict):
221
+ raise ConfigError(f"{name} section must be a mapping")
222
+ enabled = section.get("enabled")
223
+ if enabled is None:
224
+ enabled = _legacy_enabled(name, section)
225
+ if not isinstance(enabled, bool):
226
+ raise ConfigError(f"{name}.enabled must be a boolean, got {enabled!r}")
227
+ if enabled:
228
+ required = {"slack": "team_id", "jira": "site", "confluence": "site", "sentry": "organization", "aws": "allowed_services"}.get(name)
229
+ if required and not section.get(required):
230
+ raise ConfigError(f"{name}.{required} is required when {name} is enabled")
231
+ services[name] = enabled
232
+ return services
233
+
234
+
235
+ def _cli_update_config(section: object) -> CliUpdateConfig:
236
+ if section is None:
237
+ section = {}
238
+ if not isinstance(section, dict):
239
+ raise ConfigError("cli_update section must be a mapping")
240
+ enabled = section.get("enabled", False)
241
+ if not isinstance(enabled, bool):
242
+ raise ConfigError(f"cli_update.enabled must be a boolean, got {enabled!r}")
243
+ at_time = section.get("at_time", "02:00")
244
+ if not isinstance(at_time, str) or re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", at_time) is None:
245
+ raise ConfigError(f"cli_update.at_time must be HH:MM in 24-hour time, got {at_time!r}")
246
+ tzname = section.get("tzname", "America/Los_Angeles")
247
+ if not isinstance(tzname, str) or not tzname:
248
+ raise ConfigError(f"cli_update.tzname must be a non-empty string, got {tzname!r}")
249
+ try:
250
+ ZoneInfo(tzname)
251
+ except (ZoneInfoNotFoundError, ValueError):
252
+ raise ConfigError(f"cli_update.tzname {tzname!r} is not a known IANA timezone")
253
+ return CliUpdateConfig(enabled=enabled, at_time=at_time, tzname=tzname)
254
+
255
+
256
+ def _reviewer_config(section: object, config_path: str) -> ReviewerConfig:
257
+ if section is None:
258
+ section = {}
259
+ if not isinstance(section, dict):
260
+ raise ConfigError("reviewer section must be a mapping")
261
+ enabled = section.get("enabled", False)
262
+ if not isinstance(enabled, bool):
263
+ raise ConfigError(f"reviewer.enabled must be a boolean, got {enabled!r}")
264
+ name = section.get("name", "Reviewer")
265
+ if not isinstance(name, str) or not name:
266
+ raise ConfigError(f"reviewer.name must be a non-empty string, got {name!r}")
267
+ personality_file = section.get("personality_file", "")
268
+ if not isinstance(personality_file, str):
269
+ raise ConfigError(f"reviewer.personality_file must be a string, got {personality_file!r}")
270
+ personality_path = None
271
+ if personality_file:
272
+ resolved = (Path(config_path).resolve().parent / personality_file).resolve()
273
+ if not resolved.is_file():
274
+ raise ConfigError(f"reviewer.personality_file not found: {resolved}")
275
+ personality_path = str(resolved)
276
+ review_agent_prs = section.get("review_agent_prs", True)
277
+ if not isinstance(review_agent_prs, bool):
278
+ raise ConfigError(f"reviewer.review_agent_prs must be a boolean, got {review_agent_prs!r}")
279
+ commit_name = section.get("commit_name") or name # git author defaults to the display name
280
+ if not isinstance(commit_name, str) or not commit_name:
281
+ raise ConfigError(f"reviewer.commit_name must be a non-empty string, got {commit_name!r}")
282
+ commit_email = section.get("commit_email", "")
283
+ if not isinstance(commit_email, str):
284
+ raise ConfigError(f"reviewer.commit_email must be a string, got {commit_email!r}")
285
+ if enabled and not commit_email:
286
+ raise ConfigError("reviewer.commit_email is required when the reviewer is enabled")
287
+ return ReviewerConfig(
288
+ enabled=enabled,
289
+ name=name,
290
+ personality_path=personality_path,
291
+ review_agent_prs=review_agent_prs,
292
+ commit_name=commit_name,
293
+ commit_email=commit_email,
294
+ )
295
+
296
+
297
+ def _dashboard_config(section: object, agent_name: str) -> DashboardConfig:
298
+ if section is None:
299
+ section = {}
300
+ if not isinstance(section, dict):
301
+ raise ConfigError("dashboard section must be a mapping")
302
+ enabled = section.get("enabled", False)
303
+ if not isinstance(enabled, bool):
304
+ raise ConfigError(f"dashboard.enabled must be a boolean, got {enabled!r}")
305
+ bind = section.get("bind", "127.0.0.1")
306
+ if bind not in ("127.0.0.1", "0.0.0.0"):
307
+ raise ConfigError(f"dashboard.bind must be '127.0.0.1' or '0.0.0.0', got {bind!r}")
308
+ port = section.get("port", 8787)
309
+ if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
310
+ raise ConfigError(f"dashboard.port must be an integer between 1 and 65535, got {port!r}")
311
+ domain = section.get("allowed_email_domain", "")
312
+ if not isinstance(domain, str) or "@" in domain:
313
+ raise ConfigError(f"dashboard.allowed_email_domain must be a bare domain like 'example.com', got {domain!r}")
314
+ if enabled and not domain:
315
+ raise ConfigError("dashboard.allowed_email_domain is required when the dashboard is enabled")
316
+ admin_emails = section.get("admin_emails") or []
317
+ if not isinstance(admin_emails, list) or not all(isinstance(item, str) and "@" in item for item in admin_emails):
318
+ raise ConfigError(f"dashboard.admin_emails must be a list of email addresses, got {admin_emails!r}")
319
+ dev_user_email = section.get("dev_user_email")
320
+ if dev_user_email is not None and not isinstance(dev_user_email, str):
321
+ raise ConfigError(f"dashboard.dev_user_email must be a string or null, got {dev_user_email!r}")
322
+ expected_alb_arn = section.get("expected_alb_arn", "")
323
+ if not isinstance(expected_alb_arn, str):
324
+ raise ConfigError(f"dashboard.expected_alb_arn must be a string, got {expected_alb_arn!r}")
325
+ public_url = section.get("public_url", "")
326
+ if not isinstance(public_url, str) or (public_url and not public_url.startswith(("http://", "https://"))):
327
+ raise ConfigError(f"dashboard.public_url must be an http(s) url or empty, got {public_url!r}")
328
+ commit = section.get("auto_commit") or {}
329
+ if not isinstance(commit, dict):
330
+ raise ConfigError("dashboard.auto_commit must be a mapping")
331
+ commit_repo = commit.get("repo", "")
332
+ if not isinstance(commit_repo, str) or (commit_repo and "/" not in commit_repo):
333
+ raise ConfigError(f"dashboard.auto_commit.repo must be 'owner/name', got {commit_repo!r}")
334
+ commit_branch = commit.get("branch", "main")
335
+ if not isinstance(commit_branch, str) or not commit_branch:
336
+ raise ConfigError(f"dashboard.auto_commit.branch must be a non-empty string, got {commit_branch!r}")
337
+ committer_name = commit.get("committer_name") or f"{agent_name} Dashboard"
338
+ if not isinstance(committer_name, str) or not committer_name:
339
+ raise ConfigError(f"dashboard.auto_commit.committer_name must be a non-empty string, got {committer_name!r}")
340
+ committer_email = commit.get("committer_email", "")
341
+ if not isinstance(committer_email, str):
342
+ raise ConfigError(f"dashboard.auto_commit.committer_email must be a string, got {committer_email!r}")
343
+ if commit_repo and not committer_email:
344
+ raise ConfigError("dashboard.auto_commit.committer_email is required when auto_commit.repo is set")
345
+ return DashboardConfig(
346
+ enabled=enabled,
347
+ bind=bind,
348
+ port=port,
349
+ allowed_email_domain=domain.lower(),
350
+ admin_emails=[item.lower() for item in admin_emails],
351
+ dev_user_email=dev_user_email or None,
352
+ expected_alb_arn=expected_alb_arn,
353
+ public_url=public_url.rstrip("/"),
354
+ commit_repo=commit_repo,
355
+ commit_branch=commit_branch,
356
+ committer_name=committer_name,
357
+ committer_email=committer_email,
358
+ )
359
+
360
+
361
+ def _slack_config(section: dict, config_path: str, enabled: bool = False) -> SlackConfig:
362
+ if not isinstance(section, dict):
363
+ raise ConfigError("slack section must be a mapping")
364
+ legacy = [key for key in ("allowed_users", "admins") if key in section]
365
+ if legacy:
366
+ raise ConfigError(f"slack.{legacy[0]} is no longer supported; migrate Slack users and model-override permissions to the top-level roles section")
367
+ if "bot_name" in section:
368
+ raise ConfigError("slack.bot_name moved to agent.name")
369
+ team_id = section.get("team_id") or ""
370
+ if not isinstance(team_id, str):
371
+ raise ConfigError(f"slack.team_id must be a string, got {team_id!r}")
372
+ ack_reaction = section.get("ack_reaction", True)
373
+ if not isinstance(ack_reaction, bool):
374
+ raise ConfigError(f"slack.ack_reaction must be a boolean, got {ack_reaction!r}")
375
+ debug_channel = section.get("debug_channel", "")
376
+ if not isinstance(debug_channel, str):
377
+ raise ConfigError(f"slack.debug_channel must be a string, got {debug_channel!r}")
378
+ messages_file = section.get("task_started_messages_file", "task_started_messages.yaml")
379
+ if not isinstance(messages_file, str):
380
+ raise ConfigError(f"slack.task_started_messages_file must be a string, got {messages_file!r}")
381
+ messages_path = str((Path(config_path).resolve().parent / messages_file).resolve()) if messages_file else None
382
+ return SlackConfig(
383
+ team_id=team_id,
384
+ allowed_channels=_str_list(section, "allowed_channels"),
385
+ enabled=enabled,
386
+ ack_reaction=ack_reaction,
387
+ debug_channel=debug_channel,
388
+ task_started_messages_path=messages_path,
389
+ )
390
+
391
+
392
+ def _str_list(section: dict, key: str) -> list[str]:
393
+ value = section.get(key) or []
394
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
395
+ raise ConfigError(f"slack.{key} must be a list of strings, got {value!r}")
396
+ return value
397
+
398
+
399
+ def role_for(roles: dict[str, Role], user_id: str) -> Role | None:
400
+ for role in roles.values():
401
+ if user_id in role.members:
402
+ return role
403
+ for role in roles.values():
404
+ if "*" in role.members:
405
+ return role
406
+ return None
407
+
408
+
409
+ def _roles_config(raw: dict, slack: SlackConfig) -> dict[str, Role]:
410
+ section = raw.get("roles") or {}
411
+ if not isinstance(section, dict):
412
+ raise ConfigError("roles section must be a mapping")
413
+ if slack.enabled and not section:
414
+ raise ConfigError("roles must contain at least one role when Slack intake is enabled")
415
+ profiles = raw.get("profiles") or {}
416
+ approved_repos = set((raw.get("github") or {}).get("approved_repos") or [])
417
+ roles: dict[str, Role] = {}
418
+ owners: dict[str, str] = {}
419
+ wildcard_roles = 0
420
+ for name, value in section.items():
421
+ if not isinstance(name, str) or not isinstance(value, dict):
422
+ raise ConfigError(f"roles.{name} must be a mapping")
423
+ members = _role_str_list(value, "members", name)
424
+ allowed_profiles = _role_str_list(value, "allowed_profiles", name)
425
+ unknown_profiles = set(allowed_profiles) - set(profiles)
426
+ if unknown_profiles:
427
+ raise ConfigError(f"roles.{name}.allowed_profiles contains unknown profiles: {sorted(unknown_profiles)}")
428
+ model_override = value.get("model_override", False)
429
+ if not isinstance(model_override, bool):
430
+ raise ConfigError(f"roles.{name}.model_override must be a boolean, got {model_override!r}")
431
+ max_budget = value.get("max_budget_usd")
432
+ if max_budget is not None and (not isinstance(max_budget, (int, float)) or isinstance(max_budget, bool) or max_budget < 0):
433
+ raise ConfigError(f"roles.{name}.max_budget_usd must be a non-negative number or null, got {max_budget!r}")
434
+ repos_value = value.get("repos")
435
+ repos = None if repos_value is None else _role_str_list(value, "repos", name)
436
+ unknown_repos = set(repos or []) - approved_repos
437
+ if unknown_repos:
438
+ raise ConfigError(f"roles.{name}.repos contains repositories outside github.approved_repos: {sorted(unknown_repos)}")
439
+ for member in members:
440
+ if member == "*":
441
+ wildcard_roles += 1
442
+ continue
443
+ if member in owners:
444
+ raise ConfigError(f"user {member!r} belongs to both roles {owners[member]!r} and {name!r}")
445
+ owners[member] = name
446
+ roles[name] = Role(name=name, members=members, allowed_profiles=allowed_profiles, model_override=model_override, max_budget_usd=float(max_budget) if max_budget is not None else None, repos=repos)
447
+ if wildcard_roles > 1:
448
+ raise ConfigError("at most one role may contain the wildcard member '*'")
449
+ return roles
450
+
451
+
452
+ def _role_str_list(section: dict, key: str, role_name: str) -> list[str]:
453
+ value = section.get(key) or []
454
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
455
+ raise ConfigError(f"roles.{role_name}.{key} must be a list of strings, got {value!r}")
456
+ return value
457
+
458
+
459
+ def _validate_profiles(raw: dict) -> None:
460
+ profiles = raw.get("profiles") or {}
461
+ if not isinstance(profiles, dict):
462
+ raise ConfigError("profiles section must be a mapping")
463
+ for name, profile in profiles.items():
464
+ if not isinstance(profile, dict):
465
+ raise ConfigError(f"profiles.{name} must be a mapping")
466
+ effort = profile.get("effort")
467
+ if effort is not None and effort not in EFFORT_LEVELS:
468
+ raise ConfigError(f"profiles.{name}.effort must be one of {', '.join(EFFORT_LEVELS)}, got {effort!r}")
469
+ thinking = profile.get("thinking")
470
+ if thinking is None:
471
+ continue
472
+ if not isinstance(thinking, dict) or thinking.get("type") not in {"adaptive", "enabled", "disabled"}:
473
+ raise ConfigError(f"profiles.{name}.thinking must be a mapping with type adaptive, enabled, or disabled")
474
+ if thinking["type"] == "enabled":
475
+ budget = thinking.get("budget_tokens")
476
+ if not isinstance(budget, int) or isinstance(budget, bool):
477
+ raise ConfigError(f"profiles.{name}.thinking enabled requires an integer budget_tokens")
478
+
479
+
480
+ def _validate_skills(raw: dict) -> None:
481
+ section = raw.get("skills")
482
+ if section is None:
483
+ section = {}
484
+ if not isinstance(section, dict):
485
+ raise ConfigError("skills section must be a mapping")
486
+ tier = section.get("tier", "opus")
487
+ profile = section.get("profile", "standard")
488
+ models = raw.get("models")
489
+ profiles = raw.get("profiles")
490
+ if not isinstance(tier, str) or (isinstance(models, dict) and tier not in models):
491
+ raise ConfigError(f"skills.tier {tier!r} is not in the model catalog")
492
+ if not isinstance(profile, str) or (isinstance(profiles, dict) and profile not in profiles):
493
+ raise ConfigError(f"skills.profile {profile!r} is not configured")
494
+
495
+
496
+ def _file_path(raw: dict, config_path: str, section_name: str) -> str | None:
497
+ if section_name not in raw:
498
+ return None
499
+ section = raw[section_name]
500
+ if not isinstance(section, dict):
501
+ raise ConfigError(f"{section_name} section must be a mapping")
502
+ file_value = section.get("file")
503
+ if not isinstance(file_value, str):
504
+ raise ConfigError(f"{section_name}.file must be a string, got {file_value!r}")
505
+ if not file_value:
506
+ return None
507
+ resolved = (Path(config_path).resolve().parent / file_value).resolve()
508
+ if not resolved.is_file():
509
+ raise ConfigError(f"{section_name}.file not found: {resolved}")
510
+ return str(resolved)
511
+
512
+
513
+ def _agent_config(raw: dict, config_path: str) -> tuple[str, str | None]:
514
+ if "personality" in raw:
515
+ raise ConfigError("the top-level personality section moved to agent.personality_file")
516
+ section = raw.get("agent")
517
+ if section is None:
518
+ section = {}
519
+ if not isinstance(section, dict):
520
+ raise ConfigError("agent section must be a mapping")
521
+ name = section.get("name", "Agent")
522
+ if not isinstance(name, str) or not name:
523
+ raise ConfigError(f"agent.name must be a non-empty string, got {name!r}")
524
+ file_value = section.get("personality_file", "")
525
+ if not isinstance(file_value, str):
526
+ raise ConfigError(f"agent.personality_file must be a string, got {file_value!r}")
527
+ if not file_value:
528
+ return name, None
529
+ resolved = (Path(config_path).resolve().parent / file_value).resolve()
530
+ if not resolved.is_file():
531
+ raise ConfigError(f"agent.personality_file not found: {resolved}")
532
+ return name, str(resolved)
533
+
534
+
535
+ def _int_at_least(section: dict, key: str, minimum: int) -> int:
536
+ value = section.get(key)
537
+ if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
538
+ raise ConfigError(f"orchestrator.{key} must be an integer >= {minimum}, got {value!r}")
539
+ return value
@@ -0,0 +1,5 @@
1
+ """mission control: the operator web dashboard, served from the orchestrator process."""
2
+
3
+ from taskboy.dashboard.app import create_app
4
+
5
+ __all__ = ["create_app"]