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
@@ -0,0 +1,263 @@
1
+ """jira cloud operations as permission-aware in-process mcp tools (JIR-001..010).
2
+
3
+ a dedicated service-account api token (no delete permissions in jira's own scheme — JIR-006/007)
4
+ never reaches the model. created issues always carry the `taskboy` + `agent-task-{id}` labels and
5
+ a task footer (JIR-008); creation checks the artifacts table and a jql label search first (JIR-009).
6
+ projects and issue types are validated against config (JIR-010).
7
+ """
8
+
9
+ import json
10
+ import logging
11
+
12
+ from taskboy.adapters._util import _error, _text, wrap
13
+ from taskboy.models import Task
14
+ from taskboy.redact import redactor
15
+ from taskboy.store import Store
16
+
17
+ logger = logging.getLogger("taskboy.jira")
18
+
19
+
20
+ class JiraAdapter:
21
+ def __init__(self, store: Store, task: Task, site: str, email: str, api_token: str, projects: list[str], issue_types: list[str], on_milestone=None, story_points_field: str = "", bot_name: str = "Agent"):
22
+ self.store = store
23
+ self.task = task
24
+ self.site = site.rstrip("/")
25
+ self.email = email
26
+ self.api_token = api_token
27
+ self.projects = projects
28
+ self.issue_types = issue_types
29
+ self.on_milestone = on_milestone
30
+ self.story_points_field = story_points_field
31
+ self.bot_name = bot_name
32
+
33
+ # -- tools -------------------------------------------------------------------
34
+
35
+ async def search_issues(self, args: dict) -> dict:
36
+ data = await self._request("GET", "/rest/api/3/search/jql", params={"jql": str(args.get("jql", "")), "maxResults": min(int(args.get("max_results", 10)), 20), "fields": "summary,status,issuetype,assignee"})
37
+ lines = []
38
+ for issue in data.get("issues") or []:
39
+ fields = issue.get("fields") or {}
40
+ lines.append(f"{issue.get('key')}: {str(fields.get('summary', ''))[:120]} [{(fields.get('status') or {}).get('name')}] ({(fields.get('issuetype') or {}).get('name')})")
41
+ return _text("\n".join(lines) or "no issues matched")
42
+
43
+ async def get_issue(self, args: dict) -> dict:
44
+ key = str(args.get("key", "")).strip().upper()
45
+ data = await self._request("GET", f"/rest/api/3/issue/{key}", params={"fields": "summary,status,issuetype,assignee,labels,description,priority"})
46
+ fields = data.get("fields") or {}
47
+ summary = {
48
+ "key": data.get("key"),
49
+ "summary": fields.get("summary"),
50
+ "status": (fields.get("status") or {}).get("name"),
51
+ "type": (fields.get("issuetype") or {}).get("name"),
52
+ "assignee": (fields.get("assignee") or {}).get("displayName"),
53
+ "labels": fields.get("labels"),
54
+ "priority": (fields.get("priority") or {}).get("name"),
55
+ "description": _adf_to_text(fields.get("description"))[:2000],
56
+ }
57
+ return _text(json.dumps(summary, ensure_ascii=False))
58
+
59
+ async def search_users(self, args: dict) -> dict:
60
+ users = await self._request("GET", "/rest/api/3/user/search", params={"query": str(args.get("query", "")), "maxResults": 10})
61
+ lines = [f"{user.get('accountId')}: {user.get('displayName')} ({user.get('emailAddress') or 'hidden'})" for user in users]
62
+ return _text("\n".join(lines) or "no users matched")
63
+
64
+ async def list_boards(self, args: dict) -> dict:
65
+ params: dict = {"maxResults": 20}
66
+ project = str(args.get("project") or "").strip().upper()
67
+ if project:
68
+ params["projectKeyOrId"] = project
69
+ data = await self._request("GET", "/rest/agile/1.0/board", params=params)
70
+ lines = [f"{board.get('id')}: {board.get('name')} ({board.get('type')})" for board in data.get("values") or []]
71
+ return _text("\n".join(lines) or "no boards matched")
72
+
73
+ async def list_sprints(self, args: dict) -> dict:
74
+ board_id = int(args.get("board_id") or 0)
75
+ data = await self._request("GET", f"/rest/agile/1.0/board/{board_id}/sprint", params={"state": "active,future", "maxResults": 20})
76
+ lines = [f"{sprint.get('id')}: {sprint.get('name')} [{sprint.get('state')}]" for sprint in data.get("values") or []]
77
+ return _text("\n".join(lines) or "no active or future sprints matched")
78
+
79
+ async def create_issue(self, args: dict) -> dict:
80
+ project = str(args.get("project", "")).strip().upper()
81
+ issue_type = str(args.get("issue_type", "")).strip()
82
+ summary = str(args.get("summary", "")).strip()
83
+ if self.projects and project not in self.projects:
84
+ return _error(f"project {project!r} is not on the approved list {self.projects}")
85
+ if self.issue_types and issue_type not in self.issue_types:
86
+ return _error(f"issue type {issue_type!r} is not permitted; use one of {self.issue_types}")
87
+ if not summary:
88
+ return _error("summary is required")
89
+
90
+ # idempotency layer 1: this task already created an issue with this summary (JIR-009, ORC-012)
91
+ for artifact in self.store.artifacts_for(self.task.task_id):
92
+ if artifact["kind"] == "jira_issue":
93
+ return _text(f"this task already created {artifact['external_id']} ({artifact['url']}) — comment on or update it instead of creating another")
94
+ # idempotency layer 2: jql on the task label catches the crash-after-create window
95
+ existing = await self._request("GET", "/rest/api/3/search/jql", params={"jql": f'labels = "agent-task-{self.task.task_id}"', "maxResults": 1, "fields": "summary"})
96
+ if existing.get("issues"):
97
+ issue = existing["issues"][0]
98
+ self._record_issue(issue["key"])
99
+ return _text(f"an issue already exists for this task: {issue['key']} — update it instead of creating another")
100
+
101
+ description = str(args.get("description", "")).rstrip()
102
+ footer = f"Created by {self.bot_name} (taskboy) for Slack task {self.task.task_id} in <#{self.task.slack_channel_id}>."
103
+ fields: dict = {
104
+ "project": {"key": project},
105
+ "issuetype": {"name": issue_type},
106
+ "summary": summary,
107
+ "description": _adf(f"{description}\n\n{footer}" if description else footer),
108
+ "labels": ["taskboy", f"agent-task-{self.task.task_id}"], # JIR-008
109
+ }
110
+ assignee = str(args.get("assignee_account_id") or "").strip()
111
+ parent_key = str(args.get("parent_key") or "").strip()
112
+ story_points = float(args.get("story_points") or 0)
113
+ if assignee:
114
+ fields["assignee"] = {"accountId": assignee}
115
+ if parent_key:
116
+ fields["parent"] = {"key": parent_key.upper()}
117
+ points_note = ""
118
+ if story_points > 0:
119
+ if self.story_points_field:
120
+ fields[self.story_points_field] = story_points
121
+ else:
122
+ points_note = " — story points not set — configure jira.story_points_field"
123
+ payload = {"fields": fields}
124
+ created = await self._request("POST", "/rest/api/3/issue", payload)
125
+ key = str(created["key"])
126
+ self._record_issue(key)
127
+ if self.on_milestone:
128
+ await self.on_milestone(f"Created Jira issue {key}")
129
+ return _text(f"created {key}: {self.site}/browse/{key}{points_note}")
130
+
131
+ async def add_comment(self, args: dict) -> dict:
132
+ key = str(args.get("key", "")).strip().upper()
133
+ comment = await self._request("POST", f"/rest/api/3/issue/{key}/comment", {"body": _adf(str(args.get("body", "")))})
134
+ self.store.add_artifact(self.task.task_id, "jira_comment", f"{key}/comment/{comment.get('id')}", f"{self.site}/browse/{key}")
135
+ return _text(f"commented on {key}")
136
+
137
+ async def assign_issue(self, args: dict) -> dict:
138
+ key = str(args.get("key", "")).strip().upper()
139
+ account_id = str(args.get("account_id", "")).strip()
140
+ await self._request("PUT", f"/rest/api/3/issue/{key}/assignee", {"accountId": account_id})
141
+ self.store.add_event(self.task.task_id, "tool_call", {"jira_assignment": f"{key} -> {account_id}"}, tool_name="mcp__jira__assign_issue", is_write=True)
142
+ return _text(f"assigned {key} to account {account_id}")
143
+
144
+ async def move_to_sprint(self, args: dict) -> dict:
145
+ key = str(args.get("key", "")).strip().upper()
146
+ sprint_id = int(args.get("sprint_id") or 0)
147
+ await self._request("POST", f"/rest/agile/1.0/sprint/{sprint_id}/issue", {"issues": [key]})
148
+ self.store.add_event(self.task.task_id, "tool_call", {"jira_sprint": f"{key} -> {sprint_id}"}, tool_name="mcp__jira__move_to_sprint", is_write=True)
149
+ return _text(f"moved {key} to sprint {sprint_id}")
150
+
151
+ async def set_epic(self, args: dict) -> dict:
152
+ key = str(args.get("key", "")).strip().upper()
153
+ epic_key = str(args.get("epic_key", "")).strip().upper()
154
+ await self._request("PUT", f"/rest/api/3/issue/{key}", {"fields": {"parent": {"key": epic_key}}})
155
+ self.store.add_event(self.task.task_id, "tool_call", {"jira_epic": f"{key} -> {epic_key}"}, tool_name="mcp__jira__set_epic", is_write=True)
156
+ return _text(f"set {epic_key} as the epic for {key}")
157
+
158
+ async def set_story_points(self, args: dict) -> dict:
159
+ if not self.story_points_field:
160
+ return _error("story points field is not configured — set jira.story_points_field")
161
+ key = str(args.get("key", "")).strip().upper()
162
+ points = float(args.get("points") or 0)
163
+ await self._request("PUT", f"/rest/api/3/issue/{key}", {"fields": {self.story_points_field: points}})
164
+ self.store.add_event(self.task.task_id, "tool_call", {"jira_story_points": f"{key} -> {points}"}, tool_name="mcp__jira__set_story_points", is_write=True)
165
+ return _text(f"set {key} story points to {points:g}")
166
+
167
+ async def transition_issue(self, args: dict) -> dict:
168
+ key = str(args.get("key", "")).strip().upper()
169
+ wanted = str(args.get("transition", "")).strip().lower()
170
+ data = await self._request("GET", f"/rest/api/3/issue/{key}/transitions")
171
+ match = next((t for t in data.get("transitions") or [] if str(t.get("name", "")).lower() == wanted), None)
172
+ if match is None:
173
+ names = [t.get("name") for t in data.get("transitions") or []]
174
+ return _error(f"transition {wanted!r} is not available for {key}; available: {names}")
175
+ await self._request("POST", f"/rest/api/3/issue/{key}/transitions", {"transition": {"id": match["id"]}})
176
+ self.store.add_event(self.task.task_id, "tool_call", {"jira_transition": f"{key} -> {match.get('name')}"}, tool_name="mcp__jira__transition_issue", is_write=True)
177
+ return _text(f"transitioned {key} to {match.get('name')}")
178
+
179
+ async def link_pr(self, args: dict) -> dict:
180
+ """attach a github pull request to a jira issue as a remote link (JIR-005, GIT-013)."""
181
+ key = str(args.get("key", "")).strip().upper()
182
+ url = str(args.get("pr_url", "")).strip()
183
+ if not url.startswith("https://github.com/"):
184
+ return _error("pr_url must be a github pull request url")
185
+ await self._request("POST", f"/rest/api/3/issue/{key}/remotelink", {"object": {"url": url, "title": str(args.get("title", "")) or url}})
186
+ self.store.add_artifact(self.task.task_id, "jira_link", f"{key} -> {url}", url)
187
+ return _text(f"linked {url} to {key}")
188
+
189
+ # -- plumbing ------------------------------------------------------------------
190
+
191
+ def _record_issue(self, key: str) -> None:
192
+ self.store.add_artifact(self.task.task_id, "jira_issue", key, f"{self.site}/browse/{key}")
193
+
194
+ async def _request(self, method: str, path: str, payload: dict | None = None, params: dict | None = None) -> dict:
195
+ """the http seam — patched in unit tests. basic auth with the service-account token (TOL-007)."""
196
+ import aiohttp
197
+
198
+ auth = aiohttp.BasicAuth(self.email, self.api_token)
199
+ async with aiohttp.ClientSession(auth=auth) as session:
200
+ async with session.request(method, self.site + path, json=payload, params=params, headers={"Accept": "application/json"}) as response:
201
+ if response.status >= 300:
202
+ body = redactor.redact(await response.text())[:300]
203
+ raise RuntimeError(f"jira api {method} {path} failed: {response.status} — {body}")
204
+ return await response.json() if response.status != 204 else {}
205
+
206
+
207
+ def _adf(text: str) -> dict:
208
+ """minimal atlassian document format: one paragraph per text block."""
209
+ paragraphs = [{"type": "paragraph", "content": [{"type": "text", "text": block}]} for block in text.split("\n\n") if block.strip()]
210
+ return {"type": "doc", "version": 1, "content": paragraphs or [{"type": "paragraph", "content": [{"type": "text", "text": " "}]}]}
211
+
212
+
213
+ def _adf_to_text(node) -> str:
214
+ """flatten adf to plain text for bounded model context (TOL-006)."""
215
+ if node is None:
216
+ return ""
217
+ if isinstance(node, dict):
218
+ own = node.get("text", "")
219
+ children = "".join(_adf_to_text(child) for child in node.get("content") or [])
220
+ suffix = "\n" if node.get("type") in ("paragraph", "heading") else ""
221
+ return f"{own}{children}{suffix}"
222
+ return ""
223
+
224
+
225
+ def build_jira_server(adapter: JiraAdapter):
226
+ from claude_agent_sdk import create_sdk_mcp_server, tool
227
+
228
+ tools = [
229
+ tool("search_issues", "Search Jira issues with JQL.", {"jql": str, "max_results": int})(wrap(adapter.search_issues, logger)),
230
+ tool("search_users", "Search Jira users and return account ids for assignment.", {"query": str})(wrap(adapter.search_users, logger)),
231
+ tool("get_issue", "Get one Jira issue (summary, status, description, labels).", {"key": str})(wrap(adapter.get_issue, logger)),
232
+ tool(
233
+ "list_boards",
234
+ "List Jira boards, optionally for one project.",
235
+ {"type": "object", "properties": {"project": {"type": "string"}}, "required": []},
236
+ )(wrap(adapter.list_boards, logger)),
237
+ tool("list_sprints", "List active and future sprints on a Jira board.", {"board_id": int})(wrap(adapter.list_sprints, logger)),
238
+ tool(
239
+ "create_issue",
240
+ "Create a Jira issue in an approved project. Search for duplicates first.",
241
+ {
242
+ "type": "object",
243
+ "properties": {
244
+ "project": {"type": "string"},
245
+ "issue_type": {"type": "string"},
246
+ "summary": {"type": "string"},
247
+ "description": {"type": "string"},
248
+ "assignee_account_id": {"type": "string"},
249
+ "parent_key": {"type": "string"},
250
+ "story_points": {"type": "number"},
251
+ },
252
+ "required": ["project", "issue_type", "summary", "description"],
253
+ },
254
+ )(wrap(adapter.create_issue, logger)),
255
+ tool("add_comment", "Add a comment to a Jira issue.", {"key": str, "body": str})(wrap(adapter.add_comment, logger)),
256
+ tool("assign_issue", "Assign a Jira issue using an account id from search_users.", {"key": str, "account_id": str})(wrap(adapter.assign_issue, logger)),
257
+ tool("transition_issue", "Move a Jira issue through its workflow (e.g. 'In Progress', 'Done').", {"key": str, "transition": str})(wrap(adapter.transition_issue, logger)),
258
+ tool("move_to_sprint", "Move a Jira issue into a sprint.", {"key": str, "sprint_id": int})(wrap(adapter.move_to_sprint, logger)),
259
+ tool("set_epic", "Set the epic parent for a Jira story.", {"key": str, "epic_key": str})(wrap(adapter.set_epic, logger)),
260
+ tool("set_story_points", "Set the story point estimate on a Jira issue.", {"key": str, "points": float})(wrap(adapter.set_story_points, logger)),
261
+ tool("link_pr", "Attach a GitHub pull request link to a Jira issue.", {"key": str, "pr_url": str, "title": str})(wrap(adapter.link_pr, logger)),
262
+ ]
263
+ return create_sdk_mcp_server(name="jira", version="1.0.0", tools=tools)
@@ -0,0 +1,82 @@
1
+ """sentry diagnostics as read-only in-process mcp tools (SEN-001..006).
2
+
3
+ strictly read-only in v1 (SEN-003): the adapter implements no write operations at all.
4
+ event payloads are aggressively trimmed before entering model context (TOL-006, SEN-005).
5
+ every access is attributable via the tool-call audit trail (SEN-006).
6
+ """
7
+
8
+ import json
9
+ import logging
10
+
11
+ from taskboy.adapters._util import _error, _text, wrap
12
+ from taskboy.models import Task
13
+ from taskboy.redact import redactor
14
+ from taskboy.store import Store
15
+
16
+ logger = logging.getLogger("taskboy.sentry")
17
+
18
+ SENTRY_API = "https://sentry.io/api/0"
19
+
20
+
21
+ class SentryAdapter:
22
+ def __init__(self, store: Store, task: Task, organization: str, token: str, projects: list[str]):
23
+ self.store = store
24
+ self.task = task
25
+ self.organization = organization
26
+ self.token = token
27
+ self.projects = projects
28
+
29
+ async def list_issues(self, args: dict) -> dict:
30
+ project = str(args.get("project", "")).strip()
31
+ if self.projects and project not in self.projects:
32
+ return _error(f"project {project!r} is not on the approved list {self.projects}")
33
+ params = {"query": str(args.get("query", "is:unresolved")), "limit": str(min(int(args.get("limit", 10)), 20)), "sort": "freq"}
34
+ issues = await self._request(f"/projects/{self.organization}/{project}/issues/", params)
35
+ lines = [f"{i.get('shortId')} [{i.get('id')}]: {str(i.get('title', ''))[:120]} | {i.get('count')} events, {i.get('userCount')} users | last {i.get('lastSeen')}" for i in issues[:20]]
36
+ return _text("\n".join(lines) or "no issues matched")
37
+
38
+ async def get_issue(self, args: dict) -> dict:
39
+ issue = await self._request(f"/issues/{str(args.get('issue_id', '')).strip()}/", {})
40
+ summary = {key: issue.get(key) for key in ("shortId", "title", "culprit", "level", "status", "count", "userCount", "firstSeen", "lastSeen", "permalink")}
41
+ summary["project"] = (issue.get("project") or {}).get("slug")
42
+ return _text(json.dumps(summary, ensure_ascii=False))
43
+
44
+ async def get_latest_event(self, args: dict) -> dict:
45
+ """the stack trace, trimmed to what an investigation needs (SEN-002, SEN-005)."""
46
+ event = await self._request(f"/issues/{str(args.get('issue_id', '')).strip()}/events/latest/", {})
47
+ lines = [f"title: {event.get('title')}", f"message: {str(event.get('message', ''))[:300]}"]
48
+ for entry in event.get("entries") or []:
49
+ if entry.get("type") != "exception":
50
+ continue
51
+ for value in (entry.get("data") or {}).get("values") or []:
52
+ lines.append(f"exception: {value.get('type')}: {str(value.get('value', ''))[:200]}")
53
+ frames = ((value.get("stacktrace") or {}).get("frames") or [])[-12:]
54
+ for frame in frames:
55
+ marker = " (in app)" if frame.get("inApp") else ""
56
+ lines.append(f" {frame.get('filename')}:{frame.get('lineNo')} in {frame.get('function')}{marker}")
57
+ tags = {tag.get("key"): tag.get("value") for tag in (event.get("tags") or [])[:12]}
58
+ lines.append(f"tags: {json.dumps(tags, ensure_ascii=False)}")
59
+ return _text("\n".join(lines))
60
+
61
+ async def _request(self, path: str, params: dict):
62
+ """the http seam — patched in unit tests."""
63
+ import aiohttp
64
+
65
+ headers = {"Authorization": f"Bearer {self.token}"}
66
+ async with aiohttp.ClientSession(headers=headers) as session:
67
+ async with session.get(SENTRY_API + path, params=params) as response:
68
+ if response.status >= 300:
69
+ body = redactor.redact(await response.text())[:300]
70
+ raise RuntimeError(f"sentry api GET {path} failed: {response.status} — {body}")
71
+ return await response.json()
72
+
73
+
74
+ def build_sentry_server(adapter: SentryAdapter):
75
+ from claude_agent_sdk import create_sdk_mcp_server, tool
76
+
77
+ tools = [
78
+ tool("list_issues", "List Sentry issues in an approved project (query uses Sentry search syntax).", {"project": str, "query": str, "limit": int})(wrap(adapter.list_issues, logger)),
79
+ tool("get_issue", "Get one Sentry issue's summary by numeric id.", {"issue_id": str})(wrap(adapter.get_issue, logger)),
80
+ tool("get_latest_event", "Get the latest event for a Sentry issue: exception, stack trace, tags.", {"issue_id": str})(wrap(adapter.get_latest_event, logger)),
81
+ ]
82
+ return create_sdk_mcp_server(name="sentry", version="1.0.0", tools=tools)
@@ -0,0 +1,204 @@
1
+ """task-channel-scoped Slack history as a read-only in-process mcp tool."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from taskboy.adapters._util import _error, _text, wrap
10
+ from taskboy.models import Task
11
+ from taskboy.mrkdwn import to_mrkdwn
12
+ from taskboy.redact import redactor
13
+ from taskboy.slack_users import cached_user_profile
14
+ from taskboy.store import Store
15
+
16
+ logger = logging.getLogger("taskboy.slack_history")
17
+
18
+ HISTORY_MAX_LIMIT = 50
19
+ HISTORY_DEFAULT_LIMIT = 20
20
+ FILE_MAX_BYTES = 20 * 1024 * 1024
21
+ INLINE_TEXT_MAX_BYTES = 50 * 1024
22
+ INLINE_TEXT_MAX_CHARS = 3000
23
+ TEXT_FILETYPES = {"csv", "html", "javascript", "json", "log", "markdown", "python", "shell", "text", "xml", "yaml"}
24
+
25
+
26
+ class SlackHistoryAdapter:
27
+ def __init__(self, store: Store, task: Task, client, allowed_channels: list[str] | None = None, files_dir: str | Path | None = None):
28
+ self.store = store
29
+ self.task = task
30
+ self.client = client
31
+ self.allowed_channels = allowed_channels or []
32
+ self.files_dir = Path(files_dir) if files_dir is not None else None
33
+
34
+ async def channel_history(self, args: dict) -> dict:
35
+ oldest = str(args.get("oldest") or "") or None
36
+ latest = str(args.get("latest") or "") or None
37
+ cursor = str(args.get("cursor") or "") or None
38
+ limit = min(max(int(args.get("limit", HISTORY_DEFAULT_LIMIT)), 1), HISTORY_MAX_LIMIT)
39
+ self.store.add_event(self.task.task_id, "tool_call", {"oldest": oldest, "latest": latest, "limit": limit, "cursor": cursor}, tool_name="mcp__slack__channel_history", is_write=False)
40
+ data = await self._fetch(oldest, latest, limit, cursor)
41
+ lines = []
42
+ for message in reversed(data.get("messages") or []):
43
+ content = _message_content(message)
44
+ if not content:
45
+ continue
46
+ lines.append(f"[{_iso(message.get('ts'))}] <@{message.get('user', '')}>: {content}")
47
+ next_cursor = str((data.get("response_metadata") or {}).get("next_cursor") or "")
48
+ if next_cursor:
49
+ lines.append(f"more: pass cursor={next_cursor}")
50
+ return _text("\n".join(lines) or "no messages matched")
51
+
52
+ async def thread_replies(self, args: dict) -> dict:
53
+ channel = str(args.get("channel") or self.task.slack_channel_id)
54
+ if channel != self.task.slack_channel_id and channel not in self.allowed_channels:
55
+ return _error(f"channel {channel!r} is not allowed for this task")
56
+ thread_ts = str(args.get("thread_ts") or "")
57
+ if not thread_ts:
58
+ return _error("thread_ts is required")
59
+ limit = min(max(int(args.get("limit", HISTORY_DEFAULT_LIMIT)), 1), HISTORY_MAX_LIMIT)
60
+ self.store.add_event(self.task.task_id, "tool_call", {"channel": channel, "thread_ts": thread_ts, "limit": limit}, tool_name="mcp__slack__thread_replies", is_write=False)
61
+ data = await self.client.conversations_replies(channel=channel, ts=thread_ts, limit=limit)
62
+ lines = []
63
+ for message in data.get("messages") or []:
64
+ content = _message_content(message)
65
+ if content:
66
+ lines.append(f"[{_iso(message.get('ts'))}] <@{message.get('user', '')}>: {content}")
67
+ return _text("\n".join(lines) or "no messages matched")
68
+
69
+ async def get_file(self, args: dict) -> dict:
70
+ file_id = str(args.get("file_id") or "").strip()
71
+ if not file_id:
72
+ return _error("file_id is required")
73
+ self.store.add_event(self.task.task_id, "tool_call", {"file_id": file_id}, tool_name="mcp__slack__get_file", is_write=False)
74
+ if self.files_dir is None:
75
+ return _error("file downloads are not available for this task")
76
+ data = await self.client.files_info(file=file_id)
77
+ file = data.get("file") or {}
78
+ shared_channels = {str(channel) for channel in (file.get("channels") or []) + (file.get("groups") or []) + (file.get("ims") or [])}
79
+ readable_channels = {self.task.slack_channel_id, *self.allowed_channels}
80
+ if not shared_channels.intersection(readable_channels):
81
+ return _error(f"file {file_id} is not shared in a channel this task may read")
82
+ size = int(file.get("size") or 0)
83
+ if size > FILE_MAX_BYTES:
84
+ return _error(f"file {file_id} is larger than the 20 MB download limit")
85
+ url = str(file.get("url_private_download") or file.get("url_private") or "")
86
+ if not url:
87
+ return _error(f"file {file_id} has no download url")
88
+ content = await self._download_file(url)
89
+ if len(content) > FILE_MAX_BYTES:
90
+ return _error(f"file {file_id} is larger than the 20 MB download limit")
91
+ name = str(file.get("name") or file.get("title") or "file")
92
+ safe_name = re.sub(r"[/\\\\\x00]+", "_", name).strip() or "file"
93
+ self.files_dir.mkdir(parents=True, exist_ok=True)
94
+ destination = self.files_dir / f"{file_id}-{safe_name}"
95
+ destination.write_bytes(content)
96
+ relative_path = f"{self.files_dir.name}/{destination.name}"
97
+ mimetype = str(file.get("mimetype") or "application/octet-stream")
98
+ result = f"saved {relative_path} (name={name}, type={mimetype}, size={len(content)})"
99
+ filetype = str(file.get("filetype") or "").lower()
100
+ if len(content) <= INLINE_TEXT_MAX_BYTES and (mimetype.startswith("text/") or filetype in TEXT_FILETYPES):
101
+ result += f"\n\ncontent:\n{content.decode('utf-8', errors='replace')[:INLINE_TEXT_MAX_CHARS]}"
102
+ return _text(result)
103
+
104
+ async def user_info(self, args: dict) -> dict:
105
+ user_id = str(args.get("user") or "")
106
+ if not user_id:
107
+ return _error("user is required")
108
+ self.store.add_event(self.task.task_id, "tool_call", {"user": user_id}, tool_name="mcp__slack__user_info", is_write=False)
109
+ profile = self.store.get_slack_user(user_id)
110
+ try:
111
+ profile = await cached_user_profile(self.store, self.client, user_id)
112
+ except Exception as e:
113
+ self.store.add_error("slack", type(e).__name__, str(e), task_id=self.task.task_id, context={"operation": "users_info", "user_id": user_id})
114
+ if profile is None:
115
+ raise
116
+ return _text(json.dumps(profile, sort_keys=True))
117
+
118
+ async def send_dm(self, args: dict) -> dict:
119
+ user_id = str(args.get("user") or "").strip()
120
+ message = str(args.get("message") or "").strip()
121
+ if not user_id:
122
+ return _error("user is required")
123
+ if not message:
124
+ return _error("message is required")
125
+ self.store.add_event(self.task.task_id, "tool_call", {"user": user_id}, tool_name="mcp__slack__send_dm", is_write=True)
126
+ try:
127
+ opened = await self.client.conversations_open(users=user_id)
128
+ channel_id = str((opened.get("channel") or {}).get("id") or "")
129
+ if not channel_id:
130
+ raise RuntimeError("slack did not return a dm channel")
131
+ await self.client.chat_postMessage(channel=channel_id, text=redactor.redact(to_mrkdwn(message)))
132
+ except Exception as e:
133
+ self.store.add_error("slack", type(e).__name__, str(e), task_id=self.task.task_id, context={"operation": "send_dm", "user_id": user_id})
134
+ return _error(str(e))
135
+ return _text(f"sent a direct message to <@{user_id}>")
136
+
137
+ async def _fetch(self, oldest, latest, limit, cursor) -> dict:
138
+ kwargs = {"channel": self.task.slack_channel_id, "limit": limit}
139
+ if oldest:
140
+ kwargs["oldest"] = oldest
141
+ if latest:
142
+ kwargs["latest"] = latest
143
+ if cursor:
144
+ kwargs["cursor"] = cursor
145
+ return await self.client.conversations_history(**kwargs)
146
+
147
+ async def _download_file(self, url: str) -> bytes:
148
+ import aiohttp
149
+
150
+ headers = {"Authorization": f"Bearer {self.client.token}"}
151
+ async with aiohttp.ClientSession() as session:
152
+ async with session.get(url, headers=headers) as response:
153
+ if response.status >= 300:
154
+ raise RuntimeError(redactor.redact(f"slack file download failed: {response.status}"))
155
+ return await response.read()
156
+
157
+
158
+ def _iso(value) -> str:
159
+ try:
160
+ return datetime.fromtimestamp(float(value), timezone.utc).isoformat()
161
+ except (TypeError, ValueError, OSError):
162
+ return str(value or "")
163
+
164
+
165
+ def _message_content(message: dict) -> str:
166
+ parts = []
167
+ text = str(message.get("text") or "").strip()
168
+ if text:
169
+ parts.append(text)
170
+ for file in message.get("files") or []:
171
+ parts.append(f"(file: {file.get('name')} id={file.get('id')} type={file.get('mimetype')})")
172
+ return " ".join(parts)
173
+
174
+
175
+ def build_slack_server(adapter: SlackHistoryAdapter):
176
+ from claude_agent_sdk import create_sdk_mcp_server, tool
177
+
178
+ channel_history = tool(
179
+ "channel_history",
180
+ "Read only the Slack channel this task originated in. Thread replies appear only as parents; page older history with cursor or oldest.",
181
+ {"oldest": str, "latest": str, "limit": int, "cursor": str},
182
+ )(wrap(adapter.channel_history, logger))
183
+ thread_replies = tool(
184
+ "thread_replies",
185
+ "Read one Slack thread in the originating channel or another configured allowed channel.",
186
+ {
187
+ "type": "object",
188
+ "properties": {"channel": {"type": "string"}, "thread_ts": {"type": "string"}, "limit": {"type": "integer"}},
189
+ "required": ["thread_ts"],
190
+ },
191
+ )(wrap(adapter.thread_replies, logger))
192
+ user_info = tool("user_info", "Look up a Slack user's cached profile, refreshing it when stale.", {"user": str})(wrap(adapter.user_info, logger))
193
+ get_file = tool(
194
+ "get_file",
195
+ "Download a file attached to a Slack message in an allowed channel into the workspace (slack_files/) so it can be inspected. Pass the file id shown in channel_history/thread_replies output.",
196
+ {"file_id": str},
197
+ )(wrap(adapter.get_file, logger))
198
+ send_dm = tool(
199
+ "send_dm",
200
+ "Send a Slack direct message only when the task genuinely calls for contacting that user directly.",
201
+ {"user": str, "message": str},
202
+ )(wrap(adapter.send_dm, logger))
203
+ tools = [channel_history, thread_replies, user_info, get_file, send_dm]
204
+ return create_sdk_mcp_server(name="slack", version="1.0.0", tools=tools)
taskboy/assets.py ADDED
@@ -0,0 +1,27 @@
1
+ """locations of the assets shipped inside the installed package (wheel or editable checkout).
2
+
3
+ templates/ holds the seed material the wizard and operators copy out (config example,
4
+ personalities, skill templates, slack manifest); deploy/ holds the host runtime files
5
+ (systemd units, env example, git credential helper); ui_dist/ is the built dashboard.
6
+ """
7
+
8
+ import shutil
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+
12
+ PACKAGE_ROOT = Path(str(files("taskboy")))
13
+ TEMPLATES_ROOT = PACKAGE_ROOT / "templates"
14
+ DEPLOY_ROOT = PACKAGE_ROOT / "deploy"
15
+ UI_DIST = PACKAGE_ROOT / "ui_dist"
16
+ GIT_CRED_HELPER = DEPLOY_ROOT / "git-cred-helper.py"
17
+
18
+ EXTRACTABLE = {"templates": TEMPLATES_ROOT, "deploy": DEPLOY_ROOT}
19
+
20
+
21
+ def extract(name: str, destination: str) -> Path:
22
+ """copy a packaged asset tree (templates | deploy) into destination and return the new path."""
23
+ source = EXTRACTABLE[name]
24
+ target = Path(destination) / name
25
+ # pip byte-compiles installed .py files (the git credential helper); don't ship the cache
26
+ shutil.copytree(source, target, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
27
+ return target