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/audit.py ADDED
@@ -0,0 +1,78 @@
1
+ """audit trail tamper-evidence and off-host shipping (§10).
2
+
3
+ every task_event carries a hash chained to the previous event (computed in store._insert_event);
4
+ verify_chain recomputes it. ship_once exports new events as jsonl to the object-locked s3 bucket,
5
+ so even a compromised host cannot rewrite history that has already shipped.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+
12
+ from taskboy.store import Store, admin_event_hash, event_hash
13
+
14
+ logger = logging.getLogger("taskboy.audit")
15
+
16
+
17
+ def verify_chain(store: Store) -> tuple[bool, int]:
18
+ """recompute the hash chain; returns (intact, events_checked). rows predating the hash column are skipped."""
19
+ prev = ""
20
+ checked = 0
21
+ for row in store.events_after(0, limit=1_000_000):
22
+ if row["hash"] is not None:
23
+ expected = event_hash(prev, row["ts"], row["task_id"], row["kind"], row["tool_name"], row["detail_json"])
24
+ if row["hash"] != expected:
25
+ logger.error("audit chain broken at event id %s", row["id"])
26
+ return False, checked
27
+ checked += 1
28
+ prev = row["hash"] or ""
29
+ return True, checked
30
+
31
+
32
+ async def ship_once(store: Store, bucket: str, prefix: str = "audit") -> int:
33
+ """upload events newer than the last shipped id; the meta cursor makes retries idempotent.
34
+
35
+ sqlite stays on the event-loop thread (the connection is not shareable across threads);
36
+ only the s3 upload runs in a worker.
37
+ """
38
+ last_shipped = int(store.meta_get("audit_shipped_id") or 0)
39
+ events = store.events_after(last_shipped)
40
+ if not events:
41
+ return 0
42
+ key = f"{prefix}/events-{events[0]['id']:012d}-{events[-1]['id']:012d}.jsonl"
43
+ await asyncio.to_thread(_put, bucket, key, "\n".join(json.dumps(event) for event in events))
44
+ store.meta_set("audit_shipped_id", str(events[-1]["id"]))
45
+ logger.info("shipped %s audit events to s3://%s/%s", len(events), bucket, key)
46
+ return len(events)
47
+
48
+
49
+ def verify_admin_chain(store: Store) -> tuple[bool, int]:
50
+ prev = ""
51
+ checked = 0
52
+ for row in store.admin_events_after(0, limit=1_000_000):
53
+ expected = admin_event_hash(prev, row["ts"], row["actor"], row["action"], row["target"], row["outcome"], row["detail_json"])
54
+ if row["hash"] != expected:
55
+ logger.error("admin audit chain broken at event id %s", row["id"])
56
+ return False, checked
57
+ prev = row["hash"]
58
+ checked += 1
59
+ return True, checked
60
+
61
+
62
+ async def ship_admin_once(store: Store, bucket: str, prefix: str = "admin-audit") -> int:
63
+ last_shipped = int(store.meta_get("admin_audit_shipped_id") or 0)
64
+ events = store.admin_events_after(last_shipped)
65
+ if not events:
66
+ return 0
67
+ key = f"{prefix}/events-{events[0]['id']:012d}-{events[-1]['id']:012d}.jsonl"
68
+ await asyncio.to_thread(_put, bucket, key, "\n".join(json.dumps(event) for event in events))
69
+ store.meta_set("admin_audit_shipped_id", str(events[-1]["id"]))
70
+ logger.info("shipped %s admin audit events to s3://%s/%s", len(events), bucket, key)
71
+ return len(events)
72
+
73
+
74
+ def _put(bucket: str, key: str, body: str) -> None:
75
+ """the s3 seam — patched in unit tests."""
76
+ import boto3
77
+
78
+ boto3.client("s3").put_object(Bucket=bucket, Key=key, Body=body.encode())
taskboy/broker.py ADDED
@@ -0,0 +1,266 @@
1
+ """credential broker: short-lived, down-scoped github app installation tokens per task (GIT-014, TOL-007, EC2-009).
2
+
3
+ sub-agents never hold a long-lived credential. git asks this broker at use-time through a
4
+ credential helper over a unix socket, authenticated by a per-task nonce; the broker mints an
5
+ installation token scoped to the task's profile (read-only vs write) and target repositories,
6
+ caches it, and re-mints when close to expiry — so github's ~1h ttl never breaks a long task.
7
+ task A's nonce is useless for task B, and every minted token is registered with the redactor.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ import secrets as pysecrets
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ import jwt
21
+
22
+ from taskboy.adapters.github_api import GitHubStatusError
23
+ from taskboy.models import Task
24
+ from taskboy.redact import redactor
25
+
26
+ logger = logging.getLogger("taskboy.broker")
27
+
28
+ GITHUB_API = "https://api.github.com"
29
+ REFRESH_MARGIN_SECONDS = 600 # re-mint when less than 10 minutes remain
30
+
31
+ # github app permissions requested per routing profile: allowlist, hook, and credential all agree (§8.4)
32
+ PROFILE_PERMISSIONS = {
33
+ "read_only": {"contents": "read", "metadata": "read", "pull_requests": "read"},
34
+ "standard": {"contents": "write", "metadata": "read", "pull_requests": "write"},
35
+ "deep": {"contents": "write", "metadata": "read", "pull_requests": "write"},
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class TaskGrant:
41
+ task_id: str
42
+ nonce: str
43
+ repositories: list[str] # short repo names (github's api takes names, not org/name)
44
+ permissions: dict
45
+ token: str | None = field(default=None, repr=False)
46
+ expires_at: float = 0.0
47
+
48
+
49
+ class CredentialBroker:
50
+ def __init__(self, app_id: str, installation_id: str, private_key: str, socket_path: str, helper_path: str):
51
+ self.app_id = app_id
52
+ self.installation_id = installation_id
53
+ self.private_key = private_key
54
+ self.socket_path = socket_path
55
+ self.helper_path = helper_path
56
+ self.grants: dict[str, TaskGrant] = {}
57
+ self._nonce_index: dict[str, str] = {}
58
+ self._server: asyncio.AbstractServer | None = None
59
+ self._app_slug_cache: str | None = None
60
+ self.accessible_repos: set[str] | None = None # short names; None = never discovered (disables the 422 fallback)
61
+
62
+ # -- task lifecycle --------------------------------------------------------
63
+
64
+ def register_task(self, task: Task, approved_repos: list[str], granted_repos: list[str] | None = None, *, hooks_path: str) -> dict[str, str]:
65
+ """returns the env vars for the task session; the future token is scoped to profile + target repos.
66
+
67
+ granted_repos are repos an operator approved mid-task; they widen the token scope beyond the task's
68
+ original classification so live git ops against a granted repo authenticate instead of 403ing (§8.4)."""
69
+ permissions = PROFILE_PERMISSIONS.get(task.profile or "", PROFILE_PERMISSIONS["read_only"])
70
+ targets = (json.loads(task.classification_json) if task.classification_json else {}).get("target_repos") or []
71
+ repos = [repo for repo in targets if repo in approved_repos]
72
+ for repo in granted_repos or []:
73
+ if repo in approved_repos and repo not in repos:
74
+ repos.append(repo)
75
+ repos = repos or list(approved_repos)
76
+ nonce = pysecrets.token_urlsafe(32)
77
+ redactor.register(nonce)
78
+ self.grants[task.task_id] = TaskGrant(task.task_id, nonce, [repo.split("/", 1)[-1] for repo in repos], permissions)
79
+ self._nonce_index[nonce] = task.task_id
80
+ return {
81
+ "TASKBOY_BROKER_SOCKET": self.socket_path,
82
+ "TASKBOY_TASK_NONCE": nonce,
83
+ "GIT_TERMINAL_PROMPT": "0",
84
+ "GIT_CONFIG_COUNT": "2",
85
+ "GIT_CONFIG_KEY_0": "credential.helper",
86
+ "GIT_CONFIG_VALUE_0": self.helper_path,
87
+ # every repo the session touches — mirror-seeded or agent-cloned — runs the workspace pre-push hook (#95)
88
+ "GIT_CONFIG_KEY_1": "core.hooksPath",
89
+ "GIT_CONFIG_VALUE_1": hooks_path,
90
+ }
91
+
92
+ def release_task(self, task_id: str) -> None:
93
+ grant = self.grants.pop(task_id, None)
94
+ if grant is None:
95
+ return
96
+ self._nonce_index.pop(grant.nonce, None)
97
+ redactor.unregister(grant.nonce)
98
+ redactor.unregister(grant.token)
99
+
100
+ async def verify(self, approved_repos: list[str] | None = None) -> None:
101
+ """startup probe: mint a minimal token so app-id/key/installation problems surface at boot, not mid-task.
102
+
103
+ also snapshots which of approved_repos the installation can actually see, so a stale
104
+ config.yaml entry gets a clear warning now instead of a raw 422 mid-task (§8.4)."""
105
+ data = await self._mint_token({"permissions": {"metadata": "read"}})
106
+ token = str(data.get("token"))
107
+ redactor.register(token)
108
+ self.accessible_repos = await self._discover_accessible_repos(token)
109
+ if self.accessible_repos is not None:
110
+ for repo in approved_repos or []:
111
+ if repo.split("/", 1)[-1] not in self.accessible_repos:
112
+ logger.warning(
113
+ "github.approved_repos entry %s is not accessible to this GitHub App installation — install the app on it or remove it from approved_repos",
114
+ repo,
115
+ )
116
+
117
+ async def _discover_accessible_repos(self, token: str) -> set[str] | None:
118
+ """paginated GET /installation/repositories; returns None (disabling the fallback) if listing fails."""
119
+ try:
120
+ accessible: set[str] = set()
121
+ page = 1
122
+ while page <= 10:
123
+ result = await self._get_github(f"/installation/repositories?per_page=100&page={page}", token)
124
+ entries = result.get("repositories") or []
125
+ accessible.update(str(entry["name"]) for entry in entries if entry.get("name"))
126
+ if len(entries) < 100:
127
+ break
128
+ page += 1
129
+ return accessible
130
+ except Exception as e:
131
+ logger.warning("failed to list installation repositories — approved_repos drift check disabled: %s", e)
132
+ return None
133
+
134
+ async def read_token(self, repositories: list[str], permissions: dict | None = None) -> tuple[str, float]:
135
+ """fetch-only installation token for the mirror cache (contents:read, metadata:read).
136
+
137
+ returns (token, expires_at_epoch), using github's actual expiry when present."""
138
+ payload: dict = {"permissions": permissions if permissions is not None else {"contents": "read", "metadata": "read"}}
139
+ if repositories:
140
+ payload["repositories"] = [repo.split("/", 1)[-1] for repo in repositories]
141
+ data = await self._mint_token(payload)
142
+ token = str(data["token"])
143
+ redactor.register(token)
144
+ expires_at = _parse_expiry(data.get("expires_at")) or time.time() + 55 * 60
145
+ return token, expires_at
146
+
147
+ async def app_slug(self) -> str:
148
+ if self._app_slug_cache is None:
149
+ data = await self._get_github("/app", self._app_jwt())
150
+ slug = str(data.get("slug") or "")
151
+ if not slug:
152
+ raise RuntimeError("github app response did not include a slug")
153
+ self._app_slug_cache = slug
154
+ return self._app_slug_cache
155
+
156
+ # -- token minting -----------------------------------------------------------
157
+
158
+ async def credentials_for_nonce(self, nonce: str) -> dict:
159
+ task_id = self._nonce_index.get(nonce)
160
+ if task_id is None:
161
+ raise PermissionError("unknown task nonce")
162
+ return {"username": "x-access-token", "password": await self.token_for_task(task_id)}
163
+
164
+ async def token_for_task(self, task_id: str) -> str:
165
+ grant = self.grants.get(task_id)
166
+ if grant is None:
167
+ raise PermissionError(f"no credential grant for task {task_id}")
168
+ if grant.token is None or grant.expires_at - time.time() < REFRESH_MARGIN_SECONDS:
169
+ await self._mint(grant)
170
+ assert grant.token is not None
171
+ return grant.token
172
+
173
+ async def _mint(self, grant: TaskGrant) -> None:
174
+ payload: dict = {"permissions": grant.permissions}
175
+ if grant.repositories:
176
+ payload["repositories"] = grant.repositories
177
+ data = await self._mint_token(payload)
178
+ redactor.unregister(grant.token)
179
+ grant.token = str(data["token"])
180
+ grant.expires_at = _parse_expiry(data.get("expires_at")) or time.time() + 55 * 60
181
+ redactor.register(grant.token)
182
+ logger.info("minted github token for %s (repos=%s, perms=%s)", grant.task_id, grant.repositories, grant.permissions)
183
+
184
+ async def _mint_token(self, payload: dict) -> dict:
185
+ """mint an installation token; on a 422 caused by inaccessible repos, retry with only accessible ones."""
186
+ try:
187
+ return await self._post_github(f"/app/installations/{self.installation_id}/access_tokens", self._app_jwt(), payload)
188
+ except GitHubStatusError as e:
189
+ requested = payload.get("repositories")
190
+ if e.status != 422 or not requested or self.accessible_repos is None or "not accessible" not in str(e):
191
+ raise
192
+ usable = [repo for repo in requested if repo in self.accessible_repos]
193
+ if not usable:
194
+ raise RuntimeError(f"the GitHub App is not installed on {', '.join(requested)} — install it on those repositories or remove them from github.approved_repos") from e
195
+ if usable == list(requested):
196
+ raise # 422 was not about our repo list after all
197
+ logger.warning("token mint 422ed; retrying without inaccessible repos %s", sorted(set(requested) - set(usable)))
198
+ return await self._post_github(f"/app/installations/{self.installation_id}/access_tokens", self._app_jwt(), {**payload, "repositories": usable})
199
+
200
+ def _app_jwt(self) -> str:
201
+ now = int(time.time())
202
+ return jwt.encode({"iat": now - 60, "exp": now + 540, "iss": str(self.app_id)}, self.private_key, algorithm="RS256")
203
+
204
+ async def _post_github(self, path: str, bearer: str, payload: dict) -> dict:
205
+ """the http seam — patched in unit tests."""
206
+ import aiohttp
207
+
208
+ async with aiohttp.ClientSession() as session:
209
+ async with session.post(GITHUB_API + path, json=payload, headers={"Authorization": f"Bearer {bearer}", "Accept": "application/vnd.github+json"}) as response:
210
+ if response.status >= 300:
211
+ body = redactor.redact(await response.text())[:300]
212
+ raise GitHubStatusError(response.status, f"github token mint failed: {response.status} — {body}")
213
+ return await response.json()
214
+
215
+ async def _get_github(self, path: str, bearer: str) -> dict:
216
+ """the app-metadata http seam — patched in unit tests."""
217
+ import aiohttp
218
+
219
+ async with aiohttp.ClientSession() as session:
220
+ async with session.get(GITHUB_API + path, headers={"Authorization": f"Bearer {bearer}", "Accept": "application/vnd.github+json"}) as response:
221
+ if response.status >= 300:
222
+ body = redactor.redact(await response.text())[:300]
223
+ raise RuntimeError(f"github api GET {path} failed: {response.status} — {body}")
224
+ return await response.json()
225
+
226
+ # -- unix socket server -------------------------------------------------------
227
+
228
+ async def start(self) -> None:
229
+ path = Path(self.socket_path)
230
+ path.parent.mkdir(parents=True, exist_ok=True)
231
+ path.unlink(missing_ok=True)
232
+ self._server = await asyncio.start_unix_server(self._handle, path=self.socket_path)
233
+ os.chmod(self.socket_path, 0o666) # task slot users must be able to connect; the nonce is the auth
234
+
235
+ async def stop(self) -> None:
236
+ if self._server is not None:
237
+ self._server.close()
238
+ await self._server.wait_closed()
239
+ Path(self.socket_path).unlink(missing_ok=True)
240
+
241
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
242
+ try:
243
+ request = json.loads(await reader.readline())
244
+ if request.get("op") == "git-credentials":
245
+ response = await self.credentials_for_nonce(str(request.get("nonce", "")))
246
+ else:
247
+ response = {"error": "unknown op"}
248
+ except PermissionError as e:
249
+ response = {"error": str(e)}
250
+ except Exception as e:
251
+ logger.exception("broker request failed")
252
+ response = {"error": redactor.redact(str(e))}
253
+ try:
254
+ writer.write((json.dumps(response) + "\n").encode())
255
+ await writer.drain()
256
+ finally:
257
+ writer.close()
258
+
259
+
260
+ def _parse_expiry(value) -> float | None:
261
+ if not value:
262
+ return None
263
+ try:
264
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
265
+ except ValueError:
266
+ return None
taskboy/classifier.py ADDED
@@ -0,0 +1,219 @@
1
+ """task classification: one small structured-output model call, then config-driven routing (MOD-001/002).
2
+
3
+ failures never guess silently: one retry, then the fixed fallback classification, always audited (MOD-005/009).
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import re
9
+
10
+ from taskboy import settings, skills
11
+ from taskboy.config import Config, ConfigError, Role, role_for
12
+ from taskboy.llm import _extract_json, extract_usage, structured_call # noqa: F401 (extract_usage re-exported for callers/tests)
13
+ from taskboy.models import EFFORT_LEVELS, Task
14
+ from taskboy.prompts import CLASSIFICATION_SCHEMA, classifier_prompt, trim_context
15
+ from taskboy.router import RoleRefusal, RoutingDecision, route, route_skill
16
+ from taskboy.store import Store
17
+
18
+ logger = logging.getLogger("taskboy.classifier")
19
+
20
+ FALLBACK_CLASSIFICATION = {
21
+ "task_type": "investigation",
22
+ "complexity": "standard",
23
+ "risk": "read_only",
24
+ "expected_duration": "under_hour",
25
+ "required_integrations": [],
26
+ "target_repos": [],
27
+ "jira_keys": [],
28
+ }
29
+
30
+ # "effort" is optional in CLASSIFICATION_SCHEMA (never a hard failure), but a stray/unrecognized value from a
31
+ # stored triage classification or a lenient model call should be dropped rather than reach the router (MOD-009)
32
+ VALID_EFFORT_VALUES = set(EFFORT_LEVELS) | {"auto"}
33
+
34
+ # issue #55: prompt guidance (classification_guidance() in prompts.py) tells the model that
35
+ # "address/fix/respond to/resolve review comments on a PR" is bug_fix, not pr_review — but a
36
+ # haiku-tier classifier following prose isn't reliable on plural/no-URL phrasings, so this is
37
+ # re-checked deterministically after the model call, before routing, and always audited.
38
+ # matches either order — "address ... comments" and "comments ... resolve them" both trigger,
39
+ # since same-intent requests routinely put the verb after the noun (issue #55 follow-up).
40
+ _REVIEW_COMMENTS_VERB_RE = re.compile(
41
+ r"\b(address|fix|respond to|resolve)\b.{0,40}\b(review )?comments\b" r"|\b(review )?comments\b.{0,40}\b(address|fix|respond to|resolve)\b",
42
+ re.IGNORECASE | re.DOTALL,
43
+ )
44
+ _PR_REFERENCE_RE = re.compile(r"\bPRs?\b|\bpull requests?\b|/pull/\d+", re.IGNORECASE)
45
+
46
+
47
+ def _apply_review_comments_guard(classification: dict, request_text: str) -> tuple[dict, str | None]:
48
+ """returns the (possibly overridden) classification and a guard name to audit, or None if untouched."""
49
+ if classification.get("task_type") != "pr_review":
50
+ return classification, None
51
+ if not (_REVIEW_COMMENTS_VERB_RE.search(request_text) and _PR_REFERENCE_RE.search(request_text)):
52
+ return classification, None
53
+ overridden = {**classification, "task_type": "bug_fix", "risk": "writes_code"}
54
+ return overridden, "review-comments-override"
55
+
56
+
57
+ async def stub_classify(task: Task) -> dict:
58
+ """dev/echo-mode classifier: no model call, fixed fields."""
59
+ return {"task_type": "investigation", "complexity": "standard", "routing_rationale": "stub classifier"}
60
+
61
+
62
+ class Classifier:
63
+ def __init__(self, store: Store, config: Config):
64
+ self.store = store
65
+ self.config = config
66
+ tier = (config.raw.get("classifier") or {}).get("tier", "haiku")
67
+ models = config.raw.get("models") or {}
68
+ if tier not in models:
69
+ raise ConfigError(f"classifier.tier {tier!r} is not in the model catalog")
70
+ self.model_alias = tier
71
+ self.model_id = str(models[tier]["id"])
72
+
73
+ async def classify(self, task: Task) -> dict:
74
+ return await self._classify(task)
75
+
76
+ async def _classify(self, task: Task) -> dict:
77
+ """returns the task fields set when the task moves received -> queued."""
78
+ role = role_for(self.config.roles, task.slack_user_id)
79
+ if role is None:
80
+ raise RoleRefusal(f"user {task.slack_user_id!r} has no configured role")
81
+ invocation = skills.parse_invocation(task.request_text)
82
+ if invocation and invocation[0] in skills.available(settings.SKILLS_ROOT):
83
+ name, args = invocation
84
+ skill_config = self.config.raw.get("skills") or {}
85
+ loaded = skills.load(settings.SKILLS_ROOT, name)
86
+ profile = str(loaded.profile or skill_config.get("profile", "standard"))
87
+ approved_repos = ((self.config.raw.get("github") or {}).get("approved_repos") or []) if self.config.service_enabled("github") else []
88
+ if role.repos is not None:
89
+ approved_repos = [repo for repo in approved_repos if repo in role.repos]
90
+ target_repos = []
91
+ for repo in approved_repos:
92
+ short_name = repo.split("/", 1)[-1]
93
+ if re.search(rf"(?<![a-z0-9_-]){re.escape(short_name)}(?![a-z0-9_-])", args, flags=re.IGNORECASE):
94
+ target_repos.append(repo)
95
+ classification: dict = {
96
+ "task_type": "skill",
97
+ "complexity": "standard",
98
+ "risk": "read_only" if profile == "read_only" else "writes_code_and_jira",
99
+ "expected_duration": "hours",
100
+ "required_integrations": [],
101
+ "target_repos": target_repos,
102
+ "jira_keys": [],
103
+ "skill": name,
104
+ "skill_args": args,
105
+ "skill_internal_tools": loaded.internal_tools,
106
+ }
107
+ decision = route_skill(task.model_override, self.config.raw, role=role, skill_tier=loaded.model, skill_profile=loaded.profile)
108
+ self.store.add_event(task.task_id, "classified", {**classification, "fallback": False})
109
+ self.store.add_event(task.task_id, "model_routing", {"model_alias": decision.model_alias, "model_id": decision.model_id, "profile": decision.profile, "rationale": decision.rationale})
110
+ return _task_fields(classification, decision, _review_persona(classification, self.config.reviewer.enabled))
111
+ if task.classification_json:
112
+ try:
113
+ stored = json.loads(task.classification_json)
114
+ classification = validate_classification(stored)
115
+ except (TypeError, ValueError, json.JSONDecodeError):
116
+ pass
117
+ else:
118
+ classification, guard = _apply_review_comments_guard(classification, task.request_text)
119
+ decision = route(classification["task_type"], classification["complexity"], task.model_override, self.config.raw, role=role, classifier_effort=classification.get("effort"))
120
+ detail = {**classification, "fallback": False, "source": "triage"}
121
+ if guard:
122
+ detail["guard"] = guard
123
+ self.store.add_event(task.task_id, "classified", detail)
124
+ self.store.add_event(task.task_id, "model_routing", {"model_alias": decision.model_alias, "model_id": decision.model_id, "profile": decision.profile, "rationale": decision.rationale})
125
+ return _task_fields(classification, decision, _review_persona(classification, self.config.reviewer.enabled))
126
+ classification, usage, fell_back = await self._classify_once(task, role)
127
+ classification, guard = _apply_review_comments_guard(classification, task.request_text)
128
+ decision = route(classification["task_type"], classification["complexity"], task.model_override, self.config.raw, role=role, classifier_effort=classification.get("effort"))
129
+ detail = {**classification, "fallback": fell_back}
130
+ if guard:
131
+ detail["guard"] = guard
132
+ self.store.add_event(task.task_id, "classified", detail)
133
+ self.store.add_event(task.task_id, "model_routing", {"model_alias": decision.model_alias, "model_id": decision.model_id, "profile": decision.profile, "rationale": decision.rationale})
134
+ if usage:
135
+ self.store.add_usage(task.task_id, "classifier", self.model_id, **usage)
136
+ return _task_fields(classification, decision, _review_persona(classification, self.config.reviewer.enabled))
137
+
138
+ async def _classify_once(self, task: Task, role: Role | None = None) -> tuple[dict, dict | None, bool]:
139
+ github = (self.config.raw.get("github") or {}) if self.config.service_enabled("github") else {}
140
+ approved_repos = github.get("approved_repos") or []
141
+ if role is not None and role.repos is not None:
142
+ approved_repos = [repo for repo in approved_repos if repo in role.repos]
143
+ self_repo = str(github.get("self_repo") or "")
144
+ prompt = classifier_prompt(
145
+ task.request_text,
146
+ approved_repos,
147
+ self.config.enabled_integrations(),
148
+ trim_context(task.thread_context),
149
+ self_repo=self_repo if self_repo in approved_repos else None,
150
+ bot_name=self.config.agent_name,
151
+ )
152
+ # structured_call already retries once internally, so one call here bounds the worst case at two model calls before fallback
153
+ try:
154
+ classification, usage = await self._call_model(prompt)
155
+ return classification, usage, False
156
+ except Exception as e:
157
+ logger.warning("classifier call failed for %s: %s", task.task_id, e)
158
+ self.store.add_event(task.task_id, "classifier_failed", {"what": "classifier", "error": str(e)})
159
+ self.store.add_error("classifier", type(e).__name__, str(e), task_id=task.task_id)
160
+ return dict(FALLBACK_CLASSIFICATION), None, True
161
+
162
+ async def _call_model(self, prompt: str) -> tuple[dict, dict | None]:
163
+ """the per-call API seam — patched in unit tests."""
164
+ classification, usage = await structured_call(self.model_id, prompt, CLASSIFICATION_SCHEMA)
165
+ return validate_classification(classification), usage
166
+
167
+
168
+ def _review_persona(classification: dict, reviewer_enabled: bool) -> str | None:
169
+ """only a genuine /review skill invocation runs as the reviewer: a model-labeled pr_review can be a
170
+ misclassified "address the review comments" request, which writes code and must stay with the main agent."""
171
+ if reviewer_enabled and classification.get("skill") == "review":
172
+ return "reviewer"
173
+ return None
174
+
175
+
176
+ def _task_fields(classification: dict, decision: RoutingDecision, persona: str | None = None) -> dict:
177
+ fields = {
178
+ "classification_json": json.dumps(classification),
179
+ "task_type": classification["task_type"],
180
+ "complexity": classification["complexity"],
181
+ "risk": classification["risk"],
182
+ "model_alias": decision.model_alias,
183
+ "model_id": decision.model_id,
184
+ "profile": decision.profile,
185
+ "routing_rationale": decision.rationale,
186
+ "max_budget_usd": decision.max_budget_usd,
187
+ "max_turns": decision.max_turns,
188
+ "max_runtime_minutes": decision.max_runtime_minutes,
189
+ "effort": decision.effort,
190
+ }
191
+ if persona is not None:
192
+ fields["persona"] = persona
193
+ return fields
194
+
195
+
196
+ def validate_classification(candidate) -> dict:
197
+ """lenient: only task_type/complexity are fatal (no safe default); other missing fields fill from FALLBACK_CLASSIFICATION."""
198
+ if not isinstance(candidate, dict):
199
+ raise ValueError("classifier returned no structured output")
200
+ missing = [key for key in CLASSIFICATION_SCHEMA["required"] if key not in candidate]
201
+ if "task_type" in missing or "complexity" in missing:
202
+ raise ValueError(f"classification missing fields: {missing}")
203
+ if missing:
204
+ logger.info("classification filled missing fields %s with defaults", missing)
205
+ filled = dict(candidate)
206
+ for key in missing:
207
+ filled[key] = FALLBACK_CLASSIFICATION[key]
208
+ if "effort" in filled and filled["effort"] not in VALID_EFFORT_VALUES:
209
+ filled.pop("effort")
210
+ return filled
211
+
212
+
213
+ def parse_classification(message) -> dict:
214
+ """tolerant of sdk result shapes: structured_output attr, json in result, or json embedded in prose."""
215
+ candidate = getattr(message, "structured_output", None)
216
+ if candidate is None:
217
+ result = getattr(message, "result", None)
218
+ candidate = _extract_json(result) if isinstance(result, str) else result
219
+ return validate_classification(candidate)