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/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,30 @@
1
+ """shared output and error handling for in-process MCP adapters."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from taskboy.redact import redactor
6
+
7
+ OUTPUT_LIMIT = 4000
8
+ TRUNCATION_MARKER = "\n…(output truncated at 4000 chars — narrow the request or use git/the source system for full content)"
9
+
10
+
11
+ def _text(text: str) -> dict:
12
+ value = redactor.redact(text)
13
+ if len(value) > OUTPUT_LIMIT:
14
+ value = value[: OUTPUT_LIMIT - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER
15
+ return {"content": [{"type": "text", "text": value}]}
16
+
17
+
18
+ def _error(message: str) -> dict:
19
+ return {"content": [{"type": "text", "text": f"error: {redactor.redact(message)}"}], "isError": True}
20
+
21
+
22
+ def wrap(fn: Callable, logger):
23
+ async def call(args: dict) -> dict:
24
+ try:
25
+ return await fn(args)
26
+ except Exception as e:
27
+ logger.exception("adapter tool failed")
28
+ return _error(str(e))
29
+
30
+ return call
@@ -0,0 +1,107 @@
1
+ """aws diagnostics as one read-only in-process mcp tool (AWS-001..007).
2
+
3
+ IAM is the real enforcement (AWS-005) — this adapter is the belt on those suspenders:
4
+ service and region allowlists from config (AWS-003) and a read-verb operation gate.
5
+ the harness is deployed once (host account) and reads other environments by assuming that
6
+ environment's configured per-environment diagnostics role per task, with session name
7
+ ar-<task_id> so every cloudtrail entry is task-attributable (AWS-006).
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import re
14
+ import time
15
+
16
+ from taskboy.adapters._util import _error, _text, wrap
17
+ from taskboy.models import Task
18
+ from taskboy.redact import redactor
19
+ from taskboy.store import Store
20
+
21
+ logger = logging.getLogger("taskboy.aws")
22
+
23
+ READ_VERBS = re.compile(r"^(Get|List|Describe|Lookup|Search|Filter|BatchGet|Head)")
24
+ ASSUME_EXTERNAL_ID = "taskboy" # matches the trust policy in the shell repo's infrastructure/iam.py
25
+
26
+
27
+ class AwsReadAdapter:
28
+ def __init__(self, store: Store, task: Task, allowed_services: list[str], allowed_regions: list[str], role_arns: dict[str, str] | None = None):
29
+ self.store = store
30
+ self.task = task
31
+ self.allowed_services = allowed_services
32
+ self.allowed_regions = allowed_regions
33
+ self.role_arns = role_arns or {} # environment -> diagnostics role arn; empty = local dev default chain
34
+ self._credentials: dict[str, dict] = {}
35
+
36
+ async def aws_read(self, args: dict) -> dict:
37
+ service = str(args.get("service", "")).strip().lower()
38
+ operation = str(args.get("operation", "")).strip()
39
+ environment = str(args.get("environment", "")).strip().lower() or "staging"
40
+ region = str(args.get("region", "")).strip() or (self.allowed_regions[0] if self.allowed_regions else "us-east-1")
41
+ if service not in self.allowed_services:
42
+ return _error(f"service {service!r} is not on the approved list {self.allowed_services}")
43
+ if self.allowed_regions and region not in self.allowed_regions:
44
+ return _error(f"region {region!r} is not on the approved list {self.allowed_regions}")
45
+ if not READ_VERBS.match(operation):
46
+ self.store.add_event(self.task.task_id, "security_denial", {"reason": "non-read aws operation", "service": service, "operation": operation}, tool_name="mcp__aws__aws_read", is_write=True)
47
+ return _error(f"operation {operation!r} is not a read operation; only Get/List/Describe/... are permitted")
48
+ try:
49
+ parameters = json.loads(str(args.get("parameters") or "{}"))
50
+ except json.JSONDecodeError as e:
51
+ return _error(f"parameters must be a json object: {e}")
52
+
53
+ credentials = None
54
+ if self.role_arns:
55
+ if environment not in self.role_arns:
56
+ return _error(f"environment {environment!r} is not configured; available: {sorted(self.role_arns)}")
57
+ credentials = await asyncio.to_thread(self._credentials_for, environment)
58
+ self.store.add_event(self.task.task_id, "tool_call", {"aws": f"{environment}:{region} {service}.{operation}"}, tool_name="mcp__aws__aws_read", is_write=False) # AWS-006
59
+ result = await asyncio.to_thread(self._call, service, operation, region, parameters, credentials)
60
+ return _text(json.dumps(result, default=str, ensure_ascii=False))
61
+
62
+ def _credentials_for(self, environment: str) -> dict:
63
+ cached = self._credentials.get(environment)
64
+ if cached and cached["expiration_ts"] - time.time() > 300:
65
+ return cached
66
+ self._credentials[environment] = self._assume(environment)
67
+ return self._credentials[environment]
68
+
69
+ def _assume(self, environment: str) -> dict:
70
+ """the sts seam — patched in unit tests."""
71
+ import boto3
72
+
73
+ sts = boto3.client("sts")
74
+ response = sts.assume_role(RoleArn=self.role_arns[environment], RoleSessionName=f"ar-{self.task.task_id}"[:64], ExternalId=ASSUME_EXTERNAL_ID, DurationSeconds=3600)
75
+ credentials = response["Credentials"]
76
+ redactor.register(credentials["SecretAccessKey"])
77
+ redactor.register(credentials["SessionToken"])
78
+ return {
79
+ "aws_access_key_id": credentials["AccessKeyId"],
80
+ "aws_secret_access_key": credentials["SecretAccessKey"],
81
+ "aws_session_token": credentials["SessionToken"],
82
+ "expiration_ts": credentials["Expiration"].timestamp(),
83
+ }
84
+
85
+ def _call(self, service: str, operation: str, region: str, parameters: dict, credentials: dict | None):
86
+ """the boto3 seam — patched in unit tests. credentials never reach the model (TOL-007)."""
87
+ import boto3
88
+ from botocore import xform_name
89
+
90
+ kwargs = {key: credentials[key] for key in ("aws_access_key_id", "aws_secret_access_key", "aws_session_token")} if credentials else {}
91
+ client = boto3.client(service, region_name=region, **kwargs)
92
+ response = getattr(client, xform_name(operation))(**parameters)
93
+ response.pop("ResponseMetadata", None)
94
+ return response
95
+
96
+
97
+ def build_aws_server(adapter: AwsReadAdapter):
98
+ from claude_agent_sdk import create_sdk_mcp_server, tool
99
+
100
+ tools = [
101
+ tool(
102
+ "aws_read",
103
+ "Run a read-only AWS API call in a specific environment (staging|sandbox|production), e.g. environment=production service=logs operation=FilterLogEvents parameters='{\"logGroupName\": ...}'. Writes are denied by IAM and by this tool.",
104
+ {"environment": str, "service": str, "operation": str, "region": str, "parameters": str},
105
+ )(wrap(adapter.aws_read, logger)),
106
+ ]
107
+ return create_sdk_mcp_server(name="aws", version="1.0.0", tools=tools)
@@ -0,0 +1,88 @@
1
+ """read-only Confluence Cloud search and page retrieval tools."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from html.parser import HTMLParser
7
+
8
+ from taskboy.adapters._util import _error, _text, wrap
9
+ from taskboy.models import Task
10
+ from taskboy.redact import redactor
11
+ from taskboy.store import Store
12
+
13
+ logger = logging.getLogger("taskboy.confluence")
14
+
15
+
16
+ class ConfluenceAdapter:
17
+ def __init__(self, store: Store, task: Task, site: str, email: str, api_token: str, spaces: list[str]):
18
+ self.store = store
19
+ self.task = task
20
+ self.site = site.rstrip("/")
21
+ self.email = email
22
+ self.api_token = api_token
23
+ self.spaces = spaces
24
+
25
+ async def search_pages(self, args: dict) -> dict:
26
+ cql = str(args.get("cql") or "").strip()
27
+ if self.spaces:
28
+ allowed = ", ".join(f'"{space.replace(chr(34), chr(92) + chr(34))}"' for space in self.spaces)
29
+ cql = f"({cql}) AND space in ({allowed})" if cql else f"space in ({allowed})"
30
+ max_results = min(max(int(args.get("max_results", 10)), 1), 25)
31
+ self.store.add_event(self.task.task_id, "tool_call", {"cql": cql, "max_results": max_results}, tool_name="mcp__confluence__search_pages", is_write=False)
32
+ data = await self._request("GET", "/wiki/rest/api/content/search", params={"cql": cql, "limit": max_results, "expand": "space,version"})
33
+ lines = []
34
+ for page in data.get("results") or []:
35
+ space = (page.get("space") or {}).get("key")
36
+ lines.append(f"{page.get('id')}: {page.get('title')} [{space}] v{(page.get('version') or {}).get('number')}")
37
+ return _text("\n".join(lines) or "no pages matched")
38
+
39
+ async def get_page(self, args: dict) -> dict:
40
+ page_id = str(args.get("page_id") or "").strip()
41
+ self.store.add_event(self.task.task_id, "tool_call", {"page_id": page_id}, tool_name="mcp__confluence__get_page", is_write=False)
42
+ data = await self._request("GET", f"/wiki/rest/api/content/{page_id}", params={"expand": "body.storage,space,version"})
43
+ space = str((data.get("space") or {}).get("key") or "")
44
+ if self.spaces and space not in self.spaces:
45
+ return _error(f"page space {space!r} is not on the approved list {self.spaces}")
46
+ body = _html_to_text(str(((data.get("body") or {}).get("storage") or {}).get("value") or ""))
47
+ result = {"id": data.get("id"), "title": data.get("title"), "space": space, "version": (data.get("version") or {}).get("number"), "body": body}
48
+ return _text(json.dumps(result, ensure_ascii=False))
49
+
50
+ async def _request(self, method: str, path: str, params: dict | None = None) -> dict:
51
+ import aiohttp
52
+
53
+ auth = aiohttp.BasicAuth(self.email, self.api_token)
54
+ async with aiohttp.ClientSession(auth=auth) as session:
55
+ async with session.request(method, self.site + path, params=params, headers={"Accept": "application/json"}) as response:
56
+ if response.status >= 300:
57
+ body = redactor.redact(await response.text())[:300]
58
+ raise RuntimeError(f"confluence api {method} {path} failed: {response.status} — {body}")
59
+ return await response.json()
60
+
61
+
62
+ class _StorageText(HTMLParser):
63
+ def __init__(self):
64
+ super().__init__(convert_charrefs=True)
65
+ self.parts: list[str] = []
66
+
67
+ def handle_data(self, data: str) -> None:
68
+ self.parts.append(data)
69
+
70
+ def handle_starttag(self, tag: str, attrs) -> None:
71
+ if tag in {"p", "br", "li", "h1", "h2", "h3", "h4", "h5", "h6", "tr"}:
72
+ self.parts.append("\n")
73
+
74
+
75
+ def _html_to_text(value: str) -> str:
76
+ parser = _StorageText()
77
+ parser.feed(value)
78
+ return re.sub(r"\n{3,}", "\n\n", "".join(parser.parts)).strip()
79
+
80
+
81
+ def build_confluence_server(adapter: ConfluenceAdapter):
82
+ from claude_agent_sdk import create_sdk_mcp_server, tool
83
+
84
+ tools = [
85
+ tool("search_pages", "Search readable Confluence pages with CQL. Configured space restrictions are always applied.", {"cql": str, "max_results": int})(wrap(adapter.search_pages, logger)),
86
+ tool("get_page", "Read one Confluence page as plain text. Off-allowlist spaces are refused.", {"page_id": str})(wrap(adapter.get_page, logger)),
87
+ ]
88
+ return create_sdk_mcp_server(name="confluence", version="1.0.0", tools=tools)