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,435 @@
1
+ """issues as in-process mcp tools.
2
+
3
+ the discovery skill reads past tasks/feedback/errors and records proposed issues; the
4
+ implementation skill reads approved issues and enqueues a spec-driven PR task per one; the
5
+ spec2pr skill reads its assigned spec and records the outcome. these are gated to skills that opt in
6
+ via `internal_tools:` frontmatter, and every call is on the tool-call audit trail like any other tool.
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.store import ISSUE_STATUSES, Store
15
+
16
+ logger = logging.getLogger("taskboy.issues")
17
+
18
+ # tool names the runner adds to a session's allowlist when its skill opts into each capability server
19
+ ISSUES_TOOLS = [
20
+ "mcp__issues__list_task_feedback",
21
+ "mcp__issues__list_failed_tasks",
22
+ "mcp__issues__list_recent_errors",
23
+ "mcp__issues__record_issue",
24
+ "mcp__issues__list_accepted_issues",
25
+ "mcp__issues__list_existing_issues",
26
+ "mcp__issues__get_issue",
27
+ "mcp__issues__update_issue",
28
+ "mcp__issues__list_issue_comments",
29
+ "mcp__issues__post_issue_comment",
30
+ "mcp__issues__update_issue_comment",
31
+ "mcp__issues__delete_issue_comment",
32
+ "mcp__issues__resolve_issue_comment",
33
+ "mcp__issues__finish_issue",
34
+ ]
35
+ ENQUEUE_TOOLS = ["mcp__enqueue__enqueue_spec_pr"]
36
+
37
+
38
+ class IssuesAdapter:
39
+ def __init__(self, store: Store, task: Task, approved_repos: list[str], bot_name: str = "Agent"):
40
+ self.store = store
41
+ self.task = task
42
+ self.approved_repos = approved_repos
43
+ self.bot_name = bot_name
44
+
45
+ def _audit(self, tool: str, detail: dict, is_write: bool) -> None:
46
+ self.store.add_event(self.task.task_id, "tool_call", detail, tool_name=f"mcp__issues__{tool}", is_write=is_write)
47
+
48
+ async def list_task_feedback(self, args: dict) -> dict:
49
+ limit = min(int(args.get("limit", 50) or 50), 200)
50
+ offset = max(int(args.get("offset", 0) or 0), 0)
51
+ out = []
52
+ for row in self.store.recent_feedback(limit, offset=offset):
53
+ source = self.store.get_task(row["task_id"])
54
+ out.append(
55
+ {
56
+ "task_id": row["task_id"],
57
+ "rating": row["rating"],
58
+ "comment": (row["comment"] or "")[:300],
59
+ "request": source.request_text[:300] if source else None,
60
+ "result": (source.result_summary or "")[:300] if source else None,
61
+ "state": source.state if source else None,
62
+ }
63
+ )
64
+ self._audit("list_task_feedback", {"count": len(out), "offset": offset}, False)
65
+ return _text(json.dumps(out, ensure_ascii=False) if out else "no task feedback recorded yet")
66
+
67
+ async def list_failed_tasks(self, args: dict) -> dict:
68
+ limit = min(int(args.get("limit", 50) or 50), 200)
69
+ offset = max(int(args.get("offset", 0) or 0), 0)
70
+ query = str(args.get("query", "")).strip() or None
71
+ task_type = str(args.get("task_type", "")).strip() or None
72
+ tasks = self.store.list_tasks(state="failed", limit=limit, offset=offset, query=query, task_type=task_type) + self.store.list_tasks(state="blocked", limit=limit, offset=offset, query=query, task_type=task_type)
73
+ out = [{"task_id": t.task_id, "state": t.state, "task_type": t.task_type, "request": t.request_text[:300], "error": t.error, "blocked_reason": t.blocked_reason} for t in tasks]
74
+ self._audit("list_failed_tasks", {"count": len(out), "offset": offset, "query": query, "task_type": task_type}, False)
75
+ return _text(json.dumps(out, ensure_ascii=False) if out else "no failed or blocked tasks")
76
+
77
+ async def list_recent_errors(self, args: dict) -> dict:
78
+ limit = min(int(args.get("limit", 50) or 50), 200)
79
+ offset = max(int(args.get("offset", 0) or 0), 0)
80
+ component = str(args.get("component", "")).strip() or None
81
+ kind = str(args.get("kind", "")).strip() or None
82
+ # traceback_chars stays a distinct "not provided" vs "0" so callers can opt out of the tail entirely
83
+ # (0) without disturbing the default (600, byte-identical to the old hardcoded slice)
84
+ raw_traceback_chars = args.get("traceback_chars")
85
+ traceback_chars = 600 if raw_traceback_chars is None else int(raw_traceback_chars)
86
+ traceback_chars = min(max(traceback_chars, 0), 2000)
87
+ rows = self.store.recent_errors(limit, offset=offset, component=component, kind=kind)
88
+ # counts go first so the recurring-error signal survives even when the row detail is truncated
89
+ counts: dict[tuple[str, str], int] = {}
90
+ for r in rows:
91
+ key = (r["component"], r["kind"])
92
+ counts[key] = counts.get(key, 0) + 1
93
+ summary = [{"component": component_, "kind": kind_, "count": count} for (component_, kind_), count in sorted(counts.items(), key=lambda item: -item[1])]
94
+ # the traceback tail carries the raising frame — enough to locate the fault without flooding context
95
+ out = [
96
+ {
97
+ "component": r["component"],
98
+ "kind": r["kind"],
99
+ "message": (r["message"] or "")[:300],
100
+ "traceback_tail": ((r["traceback"] or "")[-traceback_chars:] or None) if traceback_chars else None,
101
+ "task_id": r["task_id"],
102
+ "ts": r["ts"],
103
+ }
104
+ for r in rows
105
+ ]
106
+ self._audit("list_recent_errors", {"count": len(out), "offset": offset, "component": component, "kind": kind}, False)
107
+ return _text(json.dumps({"counts": summary, "errors": out}, ensure_ascii=False) if out else "no errors recorded")
108
+
109
+ async def record_issue(self, args: dict) -> dict:
110
+ repo = str(args.get("repo", "")).strip()
111
+ summary = str(args.get("summary", "")).strip()
112
+ issue_type = str(args.get("issue_type", "")).strip()
113
+ details = str(args.get("details", "")).strip()
114
+ dedupe_key = str(args.get("dedupe_key", "")).strip()
115
+ priority = args.get("priority", 50)
116
+ if not (repo and summary and issue_type and details and dedupe_key):
117
+ return _error("repo, summary, issue_type, details, and dedupe_key are all required")
118
+ if repo not in self.approved_repos:
119
+ return _error(f"repo must be one of {sorted(self.approved_repos)}")
120
+ # cheap backstop: catch a duplicate proposal under a different dedupe_key before it gets recorded.
121
+ # a matching dedupe_key is a refresh (handled by the store's upsert), not a duplicate.
122
+ normalized = summary.strip().lower()
123
+ for existing in self.store.list_issues():
124
+ if existing["repo"] == repo and existing["dedupe_key"] != dedupe_key and existing["summary"].strip().lower() == normalized:
125
+ # the store only upserts dedupe_key matches while the row is still proposed; past that, re-recording is a silent no-op
126
+ if existing["status"] == "proposed":
127
+ return _error(f"an issue with this summary already exists as #{existing['id']} (proposed); " f"re-record with its dedupe_key '{existing['dedupe_key']}' if you mean to refresh it, or choose a genuinely different issue")
128
+ return _error(f"an issue with this summary already exists as #{existing['id']} ({existing['status']}); " "it can no longer be refreshed by re-recording — choose a genuinely different issue")
129
+ row = self.store.record_issue(dedupe_key, repo, summary, issue_type, details, priority)
130
+ if row["status"] != "proposed":
131
+ # the store's upsert is guarded by WHERE status = 'proposed', so a dedupe_key match on an
132
+ # already-decided row silently wrote nothing — don't report success for a no-op
133
+ return _error(f"an issue with this dedupe_key already exists as #{row['id']} ({row['status']}); it can no longer be refreshed by re-recording — choose a genuinely different dedupe_key")
134
+ self._audit("record_issue", {"id": row["id"], "repo": repo, "dedupe_key": dedupe_key, "type": issue_type, "status": row["status"]}, True)
135
+ return _text(f"recorded issue #{row['id']} ({row['status']}); re-recording the same dedupe_key refreshes it while it stays proposed")
136
+
137
+ async def list_accepted_issues(self, args: dict) -> dict:
138
+ # only a hand-typed or retried coordinator gets here without a reservation; gate on ever-reserved so a handed-off batch can't reserve a second one
139
+ rows = self.store.issues_reserved_by(self.task.task_id)
140
+ if not self.store.has_ever_reserved(self.task.task_id):
141
+ rows = self.store.reserve_issues(self.task.task_id)
142
+ out = [{"id": r["id"], "repo": r["repo"], "summary": r["summary"], "issue_type": r["issue_type"], "details": r["details"], "priority": r["priority"]} for r in rows]
143
+ self._audit("list_accepted_issues", {"count": len(out)}, False)
144
+ return _text(json.dumps(out, ensure_ascii=False) if out else "no approved issues are waiting to be implemented")
145
+
146
+ async def list_existing_issues(self, args: dict) -> dict:
147
+ limit = min(int(args.get("limit", 200) or 200), 500)
148
+ offset = max(int(args.get("offset", 0) or 0), 0)
149
+ repo = str(args.get("repo", "")).strip() or None
150
+ status = str(args.get("status", "")).strip() or None
151
+ keys_only = bool(args.get("keys_only", False))
152
+ if repo is not None and repo not in self.approved_repos:
153
+ return _error(f"repo must be blank or one of {sorted(self.approved_repos)}")
154
+ if status is not None and status not in ISSUE_STATUSES:
155
+ return _error(f"status must be blank or one of {list(ISSUE_STATUSES)}")
156
+ rows = self.store.list_issues(status=status, limit=limit, offset=offset, repo=repo)
157
+ if keys_only:
158
+ out = [{"id": r["id"], "dedupe_key": r["dedupe_key"], "status": r["status"]} for r in rows]
159
+ else:
160
+ out = [{"id": r["id"], "dedupe_key": r["dedupe_key"], "repo": r["repo"], "summary": r["summary"], "issue_type": r["issue_type"], "status": r["status"], "priority": r["priority"]} for r in rows]
161
+ self._audit("list_existing_issues", {"count": len(out), "offset": offset, "status": status, "keys_only": keys_only}, False)
162
+ return _text(json.dumps(out, ensure_ascii=False) if out else "no issues recorded yet")
163
+
164
+ async def get_issue(self, args: dict) -> dict:
165
+ row = self.store.get_issue(int(args.get("id", 0) or 0))
166
+ if row is None:
167
+ return _error("issue not found")
168
+ keep = ("id", "repo", "summary", "issue_type", "details", "priority", "status", "spec", "pr_url")
169
+ self._audit("get_issue", {"id": row["id"], "status": row["status"]}, False)
170
+ return _text(json.dumps({key: row[key] for key in keep}, ensure_ascii=False))
171
+
172
+ async def update_issue(self, args: dict) -> dict:
173
+ issue_id = int(args.get("id", 0) or 0)
174
+ summary = str(args.get("summary", "")).strip()
175
+ details = str(args.get("details", "")).strip()
176
+ priority = args.get("priority")
177
+ if not issue_id or not summary or not details or not isinstance(priority, int) or isinstance(priority, bool) or not 1 <= priority <= 100:
178
+ return _error("id, summary, details, and priority (1-100) are required")
179
+ row = self.store.update_issue(issue_id, summary, details, priority)
180
+ if row is None:
181
+ return _error(f"issue #{issue_id} is not editable")
182
+ self._audit("update_issue", {"id": issue_id, "priority": priority}, True)
183
+ return _text(f"updated issue #{issue_id}")
184
+
185
+ def _threaded_comments(self, issue_id: int) -> list[dict]:
186
+ attachments: dict[int, list[str]] = {}
187
+ for attachment in self.store.list_issue_attachments(issue_id):
188
+ if attachment["comment_id"] is not None:
189
+ attachments.setdefault(int(attachment["comment_id"]), []).append(attachment["filename"])
190
+ roots: list[dict] = []
191
+ by_id: dict[int, dict] = {}
192
+ for row in self.store.list_issue_comments(issue_id):
193
+ item = {
194
+ "id": row["id"],
195
+ "parent_comment_id": row["parent_comment_id"],
196
+ "author": row["author"],
197
+ "body": row["body"],
198
+ "resolved": bool(row["resolved"]),
199
+ "deleted": row["deleted_at"] is not None,
200
+ "edited_at": row["edited_at"],
201
+ "created_at": row["created_at"],
202
+ "attachments": attachments.get(row["id"], []),
203
+ "replies": [],
204
+ }
205
+ by_id[row["id"]] = item
206
+ if row["parent_comment_id"] is None:
207
+ roots.append(item)
208
+ elif row["parent_comment_id"] in by_id:
209
+ by_id[row["parent_comment_id"]]["replies"].append(item)
210
+ return roots
211
+
212
+ async def list_issue_comments(self, args: dict) -> dict:
213
+ issue_id = int(args.get("issue_id", 0) or 0)
214
+ if self.store.get_issue(issue_id) is None:
215
+ return _error("issue not found")
216
+ comments = self._threaded_comments(issue_id)
217
+ self._audit("list_issue_comments", {"issue_id": issue_id, "count": self.store.count_issue_comments(issue_id)}, False)
218
+ return _text(json.dumps(comments, ensure_ascii=False))
219
+
220
+ async def post_issue_comment(self, args: dict) -> dict:
221
+ issue_id = int(args.get("issue_id", 0) or 0)
222
+ parent_id = int(args.get("parent_comment_id", 0) or 0) or None
223
+ body = str(args.get("body", "")).strip()
224
+ if not issue_id or not body:
225
+ return _error("issue_id and body are required")
226
+ try:
227
+ row = self.store.add_issue_comment(issue_id, "agent", body, parent_id)
228
+ except (LookupError, ValueError) as exc:
229
+ return _error(str(exc))
230
+ self._audit("post_issue_comment", {"issue_id": issue_id, "comment_id": row["id"], "parent_comment_id": parent_id}, True)
231
+ return _text(f"posted comment #{row['id']} on issue #{issue_id}")
232
+
233
+ async def update_issue_comment(self, args: dict) -> dict:
234
+ comment_id = int(args.get("comment_id", 0) or 0)
235
+ body = str(args.get("body", "")).strip()
236
+ if not body:
237
+ return _error("body is required")
238
+ comment = self.store.get_issue_comment(comment_id)
239
+ if comment is None:
240
+ return _error("comment not found")
241
+ if comment["author"] != "agent":
242
+ return _error("only the agent's comments can be edited")
243
+ if comment["deleted_at"] is not None:
244
+ return _error("deleted comments cannot be edited")
245
+ row = self.store.update_issue_comment(comment_id, body)
246
+ if row is None:
247
+ return _error("comment could not be edited")
248
+ self._audit("update_issue_comment", {"comment_id": comment_id}, True)
249
+ return _text(f"updated comment #{comment_id}")
250
+
251
+ async def delete_issue_comment(self, args: dict) -> dict:
252
+ comment_id = int(args.get("comment_id", 0) or 0)
253
+ comment = self.store.get_issue_comment(comment_id)
254
+ if comment is None:
255
+ return _error("comment not found")
256
+ if comment["author"] != "agent":
257
+ return _error("only the agent's comments can be deleted")
258
+ if comment["deleted_at"] is not None:
259
+ return _error("comment is already deleted")
260
+ self.store.delete_issue_comment(comment_id)
261
+ self._audit("delete_issue_comment", {"comment_id": comment_id}, True)
262
+ return _text(f"deleted comment #{comment_id}")
263
+
264
+ async def resolve_issue_comment(self, args: dict) -> dict:
265
+ comment_id = int(args.get("comment_id", 0) or 0)
266
+ row = self.store.resolve_issue_comment(comment_id, "agent")
267
+ if row is None:
268
+ return _error("comment not found")
269
+ self._audit("resolve_issue_comment", {"comment_id": comment_id}, True)
270
+ return _text(f"resolved comment #{comment_id}")
271
+
272
+ async def finish_issue(self, args: dict) -> dict:
273
+ issue_id = int(args.get("id", 0) or 0)
274
+ status = str(args.get("status", "")).strip()
275
+ pr_url = str(args.get("pr_url", "")).strip() or None
276
+ if status not in ("done", "failed"):
277
+ return _error("status must be 'done' or 'failed'")
278
+ # a PR is open but not yet merged — record it as in_review; housekeeping resolves it to done/failed
279
+ stored_status = "in_review" if status == "done" and pr_url else status
280
+ row = self.store.finish_issue(issue_id, stored_status, pr_url)
281
+ if row is None:
282
+ return _error(f"issue #{issue_id} is not in progress, so its outcome cannot be recorded")
283
+ self._audit("finish_issue", {"id": issue_id, "status": stored_status, "pr_url": pr_url}, True)
284
+ if stored_status == "in_review":
285
+ return _text(f"issue #{issue_id} marked in_review — it will move to done once {pr_url} merges, or failed if it's closed unmerged")
286
+ return _text(f"issue #{issue_id} marked {stored_status}")
287
+
288
+
289
+ class EnqueueAdapter:
290
+ """lets the implementation skill hand one PR off to a fresh sub-agent task (the spec2pr skill, on its own model)."""
291
+
292
+ def __init__(self, store: Store, task: Task):
293
+ self.store = store
294
+ self.task = task
295
+
296
+ async def enqueue_spec_pr(self, args: dict) -> dict:
297
+ issue_id = int(args.get("id", 0) or 0)
298
+ spec = str(args.get("spec", "")).strip()
299
+ if not issue_id or not spec:
300
+ return _error("id (an approved issue id) and spec (the full implementation spec) are both required")
301
+ issue = self.store.get_issue(issue_id)
302
+ if issue is None:
303
+ return _error(f"issue #{issue_id} not found")
304
+ parent = self.task
305
+
306
+ # a re-call for an issue this same parent already claimed and enqueued: reply idempotently, no new claim/child
307
+ if issue["status"] == "in_progress" and issue["task_id"]:
308
+ existing_child = self.store.get_task(issue["task_id"])
309
+ if existing_child is not None and existing_child.parent_task_id == parent.task_id:
310
+ return _text(f"a PR task for issue #{issue_id} was already enqueued ({issue['task_id']})")
311
+
312
+ reserved_for_this_run = issue["status"] == "implementation_queued" and issue.get("reserved_by") == parent.task_id
313
+ legacy_manual_approval = issue["status"] == "approved"
314
+ if not (reserved_for_this_run or legacy_manual_approval):
315
+ return _error(f"issue #{issue_id} is {issue['status']}, not reserved for this run or approved — only a reserved or approved issue can be enqueued")
316
+
317
+ # claim before creating the child so a failed claim never leaves an orphan task behind
318
+ if self.store.start_issue(issue_id, None, spec) is None:
319
+ return _error(f"issue #{issue_id} could not be claimed (its status changed)")
320
+
321
+ # inherit the parent's slack context so replies land in the same thread; a per-issue message_ts keeps intake idempotent
322
+ child, created = self.store.create_task(
323
+ slack_team_id=parent.slack_team_id,
324
+ slack_channel_id=parent.slack_channel_id,
325
+ slack_thread_ts=parent.slack_thread_ts,
326
+ slack_message_ts=f"{parent.task_id}:spec:{issue_id}",
327
+ slack_user_id=parent.slack_user_id,
328
+ request_text=f"/spec2pr {issue_id}",
329
+ parent_task_id=parent.task_id,
330
+ )
331
+ if not created:
332
+ return _text(f"a PR task for issue #{issue_id} was already enqueued ({child.task_id})")
333
+ self.store.link_issue_task(issue_id, child.task_id)
334
+ self.store.add_event(parent.task_id, "issue_enqueued", {"issue_id": issue_id, "child_task_id": child.task_id}, tool_name="mcp__enqueue__enqueue_spec_pr", is_write=True)
335
+ return _text(f"enqueued PR task {child.task_id} for issue #{issue_id}; it runs the /spec2pr skill on its own model and reports its own outcome")
336
+
337
+
338
+ def build_issues_server(adapter: IssuesAdapter):
339
+ from claude_agent_sdk import create_sdk_mcp_server, tool
340
+
341
+ tools = [
342
+ tool(
343
+ "list_task_feedback",
344
+ "List recent requester feedback on tasks, with each task's request and result, to find where the system fell short. " "Use offset to page past the ~4000-char response cap.",
345
+ {
346
+ "type": "object",
347
+ "properties": {
348
+ "limit": {"type": "integer"},
349
+ "offset": {"type": "integer", "description": "skip this many rows by recency — page through a large backlog"},
350
+ },
351
+ "additionalProperties": False,
352
+ },
353
+ )(wrap(adapter.list_task_feedback, logger)),
354
+ tool(
355
+ "list_failed_tasks",
356
+ "List recent failed and blocked tasks with their error/blocked reason, to find recurring failure modes. " "Use offset to page past the ~4000-char response cap; task_type/query narrow further.",
357
+ {
358
+ "type": "object",
359
+ "properties": {
360
+ "limit": {"type": "integer"},
361
+ "offset": {"type": "integer", "description": "skip this many rows (applied per state: failed, blocked) — page through a large backlog"},
362
+ "task_type": {"type": "string", "description": "keep only rows with this exact task_type"},
363
+ "query": {"type": "string", "description": "substring match against task_id or request_text"},
364
+ },
365
+ "additionalProperties": False,
366
+ },
367
+ )(wrap(adapter.list_failed_tasks, logger)),
368
+ tool(
369
+ "list_recent_errors",
370
+ "List recent internal errors recorded across the system's components. Use component/kind to narrow " "and offset to page past the ~4000-char response cap; traceback_chars trims or (at 0) omits the traceback tail.",
371
+ {
372
+ "type": "object",
373
+ "properties": {
374
+ "limit": {"type": "integer"},
375
+ "offset": {"type": "integer", "description": "skip this many rows — page through a large backlog"},
376
+ "component": {"type": "string", "description": "exact component filter, e.g. 'review_poller'"},
377
+ "kind": {"type": "string", "description": "exact error kind filter, e.g. 'RuntimeError'"},
378
+ "traceback_chars": {"type": "integer", "description": "length of traceback_tail per row (default 600); 0 omits it"},
379
+ },
380
+ "additionalProperties": False,
381
+ },
382
+ )(wrap(adapter.list_recent_errors, logger)),
383
+ tool(
384
+ "record_issue",
385
+ "Record one repo-scoped issue. repo must be approved; dedupe_key is stable across discovery runs. issue_type may be feature_request, bug, security, reliability, performance, or another concise category.",
386
+ {"repo": str, "summary": str, "issue_type": str, "details": str, "dedupe_key": str, "priority": int},
387
+ )(wrap(adapter.record_issue, logger)),
388
+ tool("list_accepted_issues", "List the issues reserved for this implementation run, reserving up to 5 approved ones when none are reserved yet.", {})(wrap(adapter.list_accepted_issues, logger)),
389
+ tool(
390
+ "list_existing_issues",
391
+ "List existing issues so discovery can avoid recording duplicates. Check this before record_issue. " "Use status/offset to page through the full table past the ~4000-char response cap; keys_only returns " "compact id/dedupe_key/status rows so more fit per call.",
392
+ {
393
+ "type": "object",
394
+ "properties": {
395
+ "limit": {"type": "integer"},
396
+ "offset": {"type": "integer", "description": "skip this many rows — page through a large backlog"},
397
+ "repo": {"type": "string"},
398
+ "status": {"type": "string", "description": f"one of {list(ISSUE_STATUSES)}"},
399
+ "keys_only": {"type": "boolean", "description": "return only id/dedupe_key/status per row, to fit more rows under the truncation cap"},
400
+ },
401
+ "additionalProperties": False,
402
+ },
403
+ )(wrap(adapter.list_existing_issues, logger)),
404
+ tool("get_issue", "Get one issue by id, including its stored implementation spec.", {"id": int})(wrap(adapter.get_issue, logger)),
405
+ tool("update_issue", "Update an editable issue's title, markdown description, and priority after reconciling its discussion.", {"id": int, "summary": str, "details": str, "priority": int})(wrap(adapter.update_issue, logger)),
406
+ tool("list_issue_comments", "List an issue's threaded discussion, resolved/deleted state, and attachment filenames.", {"issue_id": int})(wrap(adapter.list_issue_comments, logger)),
407
+ tool(
408
+ "post_issue_comment",
409
+ f"Post a markdown comment or optional one-level reply as {adapter.bot_name}.",
410
+ {
411
+ "type": "object",
412
+ "properties": {"issue_id": {"type": "integer"}, "body": {"type": "string"}, "parent_comment_id": {"type": "integer"}},
413
+ "required": ["issue_id", "body"],
414
+ "additionalProperties": False,
415
+ },
416
+ )(wrap(adapter.post_issue_comment, logger)),
417
+ tool("update_issue_comment", f"Edit one of {adapter.bot_name}'s existing comments.", {"comment_id": int, "body": str})(wrap(adapter.update_issue_comment, logger)),
418
+ tool("delete_issue_comment", f"Soft-delete one of {adapter.bot_name}'s stale comments.", {"comment_id": int})(wrap(adapter.delete_issue_comment, logger)),
419
+ tool("resolve_issue_comment", "Mark an answered discussion comment resolved.", {"comment_id": int})(wrap(adapter.resolve_issue_comment, logger)),
420
+ tool(
421
+ "finish_issue",
422
+ "Record the outcome of an in-progress issue: status 'done' (with the pr_url — stored as in_review until the PR merges) or 'failed'.",
423
+ {"id": int, "status": str, "pr_url": str},
424
+ )(wrap(adapter.finish_issue, logger)),
425
+ ]
426
+ return create_sdk_mcp_server(name="issues", version="1.0.0", tools=tools)
427
+
428
+
429
+ def build_enqueue_server(adapter: EnqueueAdapter):
430
+ from claude_agent_sdk import create_sdk_mcp_server, tool
431
+
432
+ tools = [
433
+ tool("enqueue_spec_pr", "Hand one approved issue off to a fresh sub-agent that opens a PR. Pass the issue id and the full implementation spec (markdown). Call once per issue.", {"id": int, "spec": str})(wrap(adapter.enqueue_spec_pr, logger)),
434
+ ]
435
+ return create_sdk_mcp_server(name="enqueue", version="1.0.0", tools=tools)