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/slack.py ADDED
@@ -0,0 +1,666 @@
1
+ """slack intake and outbound notifications: bolt asyncapp in socket mode.
2
+
3
+ intake: app_mention -> dedup -> authz allowlist -> accept_task -> ack in the originating thread.
4
+ outbound: SlackNotifier implements the notifier interface over the bolt web client.
5
+
6
+ all decision logic lives in plain functions (handle_mention, authorization_failure) so tests
7
+ never need bolt objects or http mocking — the bolt wiring in build() is a thin shell.
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ import time
13
+ import traceback
14
+
15
+ from slack_bolt.async_app import AsyncApp
16
+ from slack_sdk.errors import SlackApiError
17
+
18
+ from taskboy import settings, skills
19
+ from taskboy.config import Config, Role, SlackConfig, role_for
20
+ from taskboy.models import BLOCKED, EFFORT_LEVELS, QUEUED, Task
21
+ from taskboy.mrkdwn import to_mrkdwn
22
+ from taskboy.orchestrator import accept_task
23
+ from taskboy.personality import load as load_text_file
24
+ from taskboy.redact import redactor
25
+ from taskboy.slack_users import cached_user_profile
26
+ from taskboy.started_messages import pick as pick_started_message
27
+ from taskboy.store import Store, TransitionRaced
28
+
29
+ logger = logging.getLogger("taskboy.slack")
30
+
31
+ THREAD_CONTEXT_MAX_MESSAGES = 30
32
+ THREAD_CONTEXT_MAX_CHARS = 6000
33
+ THREAD_CONTEXT_BOT_MESSAGE_MAX_CHARS = 500
34
+ SNIPPET_THRESHOLD = 3500
35
+
36
+ # non-transient channel-delivery failures (#83): the channel stays read-only/gone on retry, so
37
+ # these are worth a fallback delivery instead of a raise-and-lose-the-message loop.
38
+ UNDELIVERABLE_CHANNEL_ERRORS = frozenset({"restricted_action_read_only_channel", "channel_not_found", "not_in_channel", "is_archived"})
39
+
40
+
41
+ def authorization_failure(slack: SlackConfig, roles: dict[str, Role], team_id: str, channel_id: str, user_id: str, channel_type: str | None = None, bot_name: str = "Agent") -> str | None:
42
+ """returns a refusal reason, or None when the mention is authorized (SLK-002).
43
+
44
+ socket mode's app-level token already binds us to one workspace, so a missing team field is tolerated.
45
+ an empty channel allowlist means "any channel the bot was invited to" — the invite is the gate.
46
+ """
47
+ if slack.team_id and team_id and team_id != slack.team_id:
48
+ return f"this workspace is not authorized to use {bot_name}"
49
+ if slack.allowed_channels and not channel_id.startswith("D") and channel_type != "im" and channel_id not in slack.allowed_channels:
50
+ return f"this channel is not on {bot_name}'s allowlist — ask an admin to add it"
51
+ if role_for(roles, user_id) is None:
52
+ return f"you do not have a configured role for {bot_name} — ask an admin to add you"
53
+ return None
54
+
55
+
56
+ def clean_text(text: str, bot_user_id: str) -> str:
57
+ return re.sub(rf"<@{re.escape(bot_user_id)}>", "", text).strip()
58
+
59
+
60
+ def is_help_request(text: str) -> bool:
61
+ """narrow on purpose: broader matching (e.g. "please help me") would swallow ordinary task requests."""
62
+ stripped = text.strip().lower()
63
+ return stripped == "help" or bool(re.match(r"/help(\s|$)", stripped))
64
+
65
+
66
+ def help_text(config: Config) -> str:
67
+ loaded = load_text_file(config.help_path)
68
+ return loaded[0] if loaded else ""
69
+
70
+
71
+ def normalize_effort(raw: str | None) -> str | None:
72
+ """tolerant mapping of admin-typed effort onto the sdk's levels; None when unrecognizable."""
73
+ if not raw:
74
+ return None
75
+ cleaned = raw.strip().lower().replace("-", "")
76
+ if not cleaned:
77
+ return None
78
+ if cleaned in EFFORT_LEVELS:
79
+ return cleaned
80
+ for level in EFFORT_LEVELS:
81
+ if level.startswith(cleaned):
82
+ return level
83
+ return None
84
+
85
+
86
+ def extract_overrides(text: str, model_aliases: list[str]) -> tuple[str | None, str | None, str]:
87
+ """pull the legacy `model:alias` or admin `!<alias>[-<effort>]` override directive out of the request text, stripping it from the returned text; matches only known catalog aliases so ordinary exclamation marks in prose are never eaten."""
88
+ match = re.search(r"\bmodel:([a-z0-9-]+)\b", text)
89
+ if match:
90
+ return match.group(1), None, (text[: match.start()] + text[match.end() :]).strip()
91
+ if not model_aliases:
92
+ return None, None, text
93
+ aliases = sorted(model_aliases, key=len, reverse=True)
94
+ pattern = rf"(?<![\w!])!({'|'.join(re.escape(a) for a in aliases)})(?:-([a-zA-Z-]+))?\b"
95
+ match = re.search(pattern, text, flags=re.IGNORECASE)
96
+ if not match:
97
+ return None, None, text
98
+ model = match.group(1).lower()
99
+ effort = normalize_effort(match.group(2))
100
+ return model, effort, (text[: match.start()] + text[match.end() :]).strip()
101
+
102
+
103
+ async def fetch_thread_transcript(client, channel_id: str, thread_ts: str, exclude_ts: str, store: Store | None = None, debug=None, bot_user_id: str | None = None, bot_name: str = "Agent") -> str | None:
104
+ messages: list[dict] = []
105
+ cursor = None
106
+ for _ in range(5):
107
+ kwargs = {"channel": channel_id, "ts": thread_ts, "limit": 200}
108
+ if cursor:
109
+ kwargs["cursor"] = cursor
110
+ try:
111
+ response = await client.conversations_replies(**kwargs)
112
+ except Exception as e:
113
+ logger.warning("conversations.replies failed for %s/%s", channel_id, thread_ts, exc_info=True)
114
+ if store is not None:
115
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "conversations_replies", "channel_id": channel_id, "thread_ts": thread_ts})
116
+ if debug is not None:
117
+ await debug.system_error("slack", f"conversations.replies failed: {e}")
118
+ if not messages:
119
+ return None
120
+ break
121
+ messages.extend(response.get("messages") or [])
122
+ next_cursor = str((response.get("response_metadata") or {}).get("next_cursor") or "")
123
+ if not next_cursor or next_cursor == cursor:
124
+ break
125
+ cursor = next_cursor
126
+ lines = []
127
+ for message in messages:
128
+ text = str(message.get("text") or "").strip()
129
+ if message.get("ts") == exclude_ts or not text:
130
+ continue
131
+ user = str(message.get("user") or "")
132
+ if user == bot_user_id:
133
+ lines.append(f"{bot_name}: {text[:THREAD_CONTEXT_BOT_MESSAGE_MAX_CHARS]}")
134
+ else:
135
+ lines.append(f"<@{user}>: {text}")
136
+ transcript = "\n".join(lines[-THREAD_CONTEXT_MAX_MESSAGES:])
137
+ if len(transcript) > THREAD_CONTEXT_MAX_CHARS:
138
+ marker = "(earlier messages omitted)\n"
139
+ transcript = marker + transcript[-(THREAD_CONTEXT_MAX_CHARS - len(marker)) :]
140
+ return transcript or None
141
+
142
+
143
+ async def fetch_dm_transcript(client, channel_id: str, exclude_ts: str, bot_user_id: str, store: Store | None = None, bot_name: str = "Agent", limit: int = 20) -> str | None:
144
+ """Fetch recent DM context in chronological order; failures never block a reply."""
145
+ try:
146
+ response = await client.conversations_history(channel=channel_id, limit=limit)
147
+ except Exception as e:
148
+ logger.warning("conversations.history failed for %s", channel_id, exc_info=True)
149
+ if store is not None:
150
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "conversations_history", "channel_id": channel_id})
151
+ return None
152
+ lines = []
153
+ for message in reversed(response.get("messages") or []):
154
+ text = str(message.get("text") or "").strip()
155
+ if message.get("ts") == exclude_ts or not text:
156
+ continue
157
+ user = str(message.get("user") or "")
158
+ speaker = bot_name if user == bot_user_id or message.get("bot_id") else f"<@{user}>"
159
+ lines.append(f"{speaker}: {text}")
160
+ transcript = "\n".join(lines[-limit:])
161
+ if len(transcript) > THREAD_CONTEXT_MAX_CHARS:
162
+ marker = "(earlier messages omitted)\n"
163
+ transcript = marker + transcript[-(THREAD_CONTEXT_MAX_CHARS - len(marker)) :]
164
+ return transcript or None
165
+
166
+
167
+ async def fetch_user_profile(store: Store, client, user_id: str, debug=None) -> dict | None:
168
+ try:
169
+ return await cached_user_profile(store, client, user_id)
170
+ except Exception as e:
171
+ logger.warning("users.info failed for %s", user_id, exc_info=True)
172
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "users_info", "user_id": user_id})
173
+ if debug is not None:
174
+ await debug.system_error("slack", f"users.info failed for {user_id}: {e}")
175
+ return store.get_slack_user(user_id)
176
+
177
+
178
+ async def handle_thread_answer(store: Store, notifier, task: Task, answer_text: str, user_id: str) -> str:
179
+ """route a thread reply as the answers to a blocked task's pending questions, and requeue it for resume."""
180
+ recorded = store.answer_questions(task.task_id, answer_text, user_id)
181
+ if recorded is None:
182
+ return "no_pending_questions"
183
+ store.add_event(task.task_id, "questions_answered", {"user": user_id})
184
+ try:
185
+ store.transition(task.task_id, BLOCKED, QUEUED, "resumed: requester answered questions", resume_session_id=task.session_id)
186
+ except TransitionRaced:
187
+ return "not_blocked" # cancelled or already resumed; the answer stays recorded for the next run's prompt
188
+ await notifier.answer(task.slack_channel_id, task.slack_thread_ts, "Got it — resuming with your answers.")
189
+ return "answered"
190
+
191
+
192
+ async def handle_thread_reply(store: Store, notifier, event: dict, event_id: str | None, bot_user_id: str) -> str:
193
+ """a plain (no-mention) channel reply only matters when the thread's latest task is waiting on answers; everything else is ignored."""
194
+ if event.get("subtype") or event.get("bot_id") or event.get("user") == bot_user_id:
195
+ return "ignored"
196
+ text = str(event.get("text") or "").strip()
197
+ thread_ts = str(event.get("thread_ts") or "")
198
+ if not thread_ts or not text or f"<@{bot_user_id}>" in text:
199
+ return "ignored" # mentions are the app_mention handler's job
200
+ task = store.latest_task_in_thread(str(event.get("channel") or ""), thread_ts)
201
+ if task is None or task.state != BLOCKED or task.slack_user_id != event.get("user") or store.pending_questions_for(task.task_id) is None:
202
+ return "ignored"
203
+ if event_id and store.slack_event_seen(event_id):
204
+ return "duplicate_event"
205
+ return await handle_thread_answer(store, notifier, task, text, str(event.get("user") or ""))
206
+
207
+
208
+ async def handle_mention(store: Store, config: Config, notifier, event: dict, event_id: str | None, bot_user_id: str, quick=None, client=None) -> tuple[Task | None, str]:
209
+ """the full intake decision for one app_mention event. returns (task, status) for tests and logging."""
210
+ if event_id and store.slack_event_seen(event_id):
211
+ return None, "duplicate_event" # slack redelivery — never a second task (SLK-008)
212
+ team_id = event.get("team", "")
213
+ channel_id = event.get("channel", "")
214
+ user_id = event.get("user", "")
215
+ message_ts = event.get("ts", "")
216
+ thread_ts = event.get("thread_ts") or message_ts
217
+ text = clean_text(event.get("text", ""), bot_user_id)
218
+ debug = getattr(notifier, "debug", None)
219
+ profile = await fetch_user_profile(store, client, user_id, debug) if client is not None and user_id else store.get_slack_user(user_id)
220
+ debug_ref = await debug.open_thread(user_id=user_id, channel_id=channel_id, request_text=text, user_profile=profile) if debug is not None else None
221
+ debug_thread_ts, debug_permalink = debug_ref or (None, None)
222
+
223
+ refusal = authorization_failure(config.slack, config.roles, team_id, channel_id, user_id, str(event.get("channel_type") or ""), bot_name=config.agent_name)
224
+ if refusal:
225
+ store.record_intake_denial(team_id, channel_id, user_id, refusal)
226
+ if debug is not None:
227
+ await debug.intake_refusal(debug_thread_ts, refusal)
228
+ await notifier.refuse_intake(channel_id, thread_ts, refusal)
229
+ return None, "unauthorized"
230
+
231
+ model_aliases = list((config.raw.get("models") or {}).keys())
232
+ override, effort, text = extract_overrides(text, model_aliases)
233
+ role = role_for(config.roles, user_id)
234
+ if not (role and role.model_override):
235
+ override = None # directive stripped but not honored for non-admins
236
+ effort = None
237
+ if not text:
238
+ reason = f"tell me what you need, e.g. `@{config.agent_name} investigate PROJ-123`"
239
+ if debug is not None:
240
+ await debug.intake_refusal(debug_thread_ts, reason)
241
+ await notifier.refuse_intake(channel_id, thread_ts, reason)
242
+ return None, "empty"
243
+
244
+ if is_help_request(text) and (guide := help_text(config)):
245
+ await notifier.answer(channel_id, thread_ts, guide)
246
+ if debug is not None:
247
+ await debug.post(debug_thread_ts, "Answered `/help` — no task created.")
248
+ return None, "help"
249
+ invocation = skills.parse_invocation(text)
250
+ if text.startswith("/"):
251
+ names = skills.available(settings.SKILLS_ROOT)
252
+ name = invocation[0] if invocation else ""
253
+ if invocation is None or name not in names:
254
+ available = ", ".join(f"/{item}" for item in names)
255
+ reason = f"unknown skill /{name} — available: {available}"
256
+ if debug is not None:
257
+ await debug.intake_refusal(debug_thread_ts, reason)
258
+ await notifier.refuse_intake(channel_id, thread_ts, reason)
259
+ return None, "unknown_skill"
260
+
261
+ # a mention inside an existing thread is a follow-up: link lineage
262
+ parent = store.latest_task_in_thread(channel_id, thread_ts) if thread_ts != message_ts else None
263
+ # a requester reply while their task is waiting on follow-up questions is the answer, not a new task
264
+ if parent is not None and parent.state == BLOCKED and parent.slack_user_id == user_id and store.pending_questions_for(parent.task_id) is not None:
265
+ answer_status = await handle_thread_answer(store, notifier, parent, text, user_id)
266
+ if answer_status == "answered":
267
+ if debug is not None:
268
+ await debug.post(parent.debug_thread_ts, f"Requester answered follow-up questions; task `{parent.task_id}` resumes.", parent.task_id)
269
+ return parent, "answered"
270
+ if answer_status == "not_blocked":
271
+ # the answer was recorded for the next run, but the task raced out of blocked (cancelled or already resumed)
272
+ await notifier.answer(channel_id, thread_ts, "I recorded your answers, but that task is no longer waiting on them.")
273
+ return parent, "answer_raced"
274
+ # no_pending_questions: the round was answered out from under us — treat as a normal follow-up
275
+ thread_context = await fetch_thread_transcript(client, channel_id, thread_ts, message_ts, store, debug, bot_user_id, config.agent_name) if thread_ts != message_ts and client is not None else None
276
+ quick_attempted = quick is not None and override is None and effort is None and invocation is None
277
+ pre_classification = None
278
+ triage_usage = None
279
+ if quick_attempted:
280
+ answered_task, pre_classification = await quick.try_answer(
281
+ channel_id=channel_id,
282
+ thread_ts=thread_ts,
283
+ user_id=user_id,
284
+ text=text,
285
+ parent=parent,
286
+ team_id=team_id,
287
+ message_ts=message_ts,
288
+ thread_context=thread_context,
289
+ debug_thread_ts=debug_thread_ts,
290
+ debug_permalink=debug_permalink,
291
+ )
292
+ if answered_task is not None:
293
+ if debug is not None:
294
+ await debug.quick_answer(answered_task)
295
+ answer = answered_task.reply or answered_task.result_summary or ""
296
+ if debug_permalink:
297
+ answer += f"\n\n[Additional Details]({debug_permalink})"
298
+ await notifier.answer(channel_id, thread_ts, answer)
299
+ return None, "quick_answer"
300
+ if pre_classification is not None:
301
+ pre_classification = dict(pre_classification)
302
+ triage_usage = pre_classification.pop("_triage_usage", None)
303
+ task, status = await accept_task(
304
+ store,
305
+ config,
306
+ notifier,
307
+ team_id=team_id,
308
+ channel_id=channel_id,
309
+ thread_ts=thread_ts,
310
+ message_ts=message_ts,
311
+ user_id=user_id,
312
+ text=text,
313
+ parent_task_id=parent.task_id if parent else None,
314
+ model_override=override,
315
+ effort_override=effort,
316
+ thread_context=thread_context,
317
+ pre_classification=pre_classification,
318
+ debug_thread_ts=debug_thread_ts,
319
+ debug_permalink=debug_permalink,
320
+ )
321
+ if triage_usage and task is not None and status != "duplicate":
322
+ store.add_usage(task.task_id, "triage", quick.model_id, **triage_usage)
323
+ if quick_attempted and task is not None and status != "duplicate":
324
+ store.add_event(task.task_id, "quick_escalated", {"escalated": True})
325
+ if status in ("paused", "queue_full"):
326
+ reason = "intake is paused right now — try again soon" if status == "paused" else "the task queue is full — try again later"
327
+ if debug is not None:
328
+ await debug.intake_refusal(debug_thread_ts, reason)
329
+ await notifier.refuse_intake(channel_id, thread_ts, reason)
330
+ return task, status
331
+
332
+
333
+ async def handle_dm(store: Store, config: Config, notifier, event: dict, event_id: str | None, bot_user_id: str, quick, client) -> str:
334
+ """Handle one direct message as chat unless the user explicitly mentions the bot."""
335
+ if event.get("subtype") or event.get("bot_id") or event.get("user") == bot_user_id:
336
+ return "ignored"
337
+ text = str(event.get("text") or "").strip()
338
+ if f"<@{bot_user_id}>" in text:
339
+ _, status = await handle_mention(store, config, notifier, event, event_id, bot_user_id, quick=quick, client=client)
340
+ return status
341
+ if event_id and store.slack_event_seen(event_id):
342
+ return "duplicate_event"
343
+ team_id = str(event.get("team") or "")
344
+ channel_id = str(event.get("channel") or "")
345
+ user_id = str(event.get("user") or "")
346
+ message_ts = str(event.get("ts") or "")
347
+ refusal = authorization_failure(config.slack, config.roles, team_id, channel_id, user_id, str(event.get("channel_type") or ""), bot_name=config.agent_name)
348
+ if refusal:
349
+ store.record_intake_denial(team_id, channel_id, user_id, refusal)
350
+ await notifier.answer(channel_id, None, refusal)
351
+ return "unauthorized"
352
+ if not text:
353
+ return "ignored"
354
+ if is_help_request(text) and (guide := help_text(config)):
355
+ await notifier.answer(channel_id, None, guide)
356
+ return "help"
357
+ if quick is None:
358
+ await notifier.answer(channel_id, None, f"This needs a full task. Mention `@{config.agent_name}` to start one.")
359
+ return "escalate"
360
+ history = await fetch_dm_transcript(client, channel_id, message_ts, bot_user_id, store, config.agent_name)
361
+ task, status = await quick.chat(channel_id=channel_id, user_id=user_id, text=text, team_id=team_id, message_ts=message_ts, history=history)
362
+ if status == "rate_limited":
363
+ await notifier.answer(channel_id, None, f"I’ve hit the quick-chat limit for you this hour. Try again later, or mention `@{config.agent_name}` to start a full task.")
364
+ elif status == "escalate":
365
+ await notifier.answer(channel_id, None, f"This needs a full task. Mention `@{config.agent_name}` to start one.")
366
+ else:
367
+ await notifier.answer(channel_id, None, task.reply or task.result_summary or "")
368
+ return status
369
+
370
+
371
+ class SlackNotifier:
372
+ """posts lifecycle updates to the originating thread (SLK-005/006/007). all outbound text is redacted."""
373
+
374
+ def __init__(self, client, progress_min_interval_seconds: int = 60, ack_reaction: bool = True, debug=None, task_started_messages_path: str | None = None, store: Store | None = None, reviewer_name: str = "Reviewer", dashboard_url: str = ""):
375
+ self.client = client
376
+ self.progress_min_interval_seconds = progress_min_interval_seconds
377
+ self.ack_reaction = ack_reaction
378
+ self._last_progress: dict[str, float] = {}
379
+ self.debug = debug
380
+ self.task_started_messages_path = task_started_messages_path
381
+ self.store = store
382
+ self.reviewer_name = reviewer_name
383
+ self.dashboard_url = dashboard_url.rstrip("/")
384
+
385
+ def _record_error(self, operation: str, error: Exception, task_id: str | None = None) -> None:
386
+ if self.store is not None:
387
+ self.store.add_error("slack", type(error).__name__, str(error), task_id=task_id, traceback=traceback.format_exc(), context={"operation": operation})
388
+
389
+ async def _deliver_fallback(self, user_id: str, text: str, task_id: str | None = None) -> None:
390
+ """dm the requester when the channel is read-only/gone (#83); debug feed if that fails."""
391
+ if user_id:
392
+ try:
393
+ opened = await self.client.conversations_open(users=user_id)
394
+ channel_id = str((opened.get("channel") or {}).get("id") or "")
395
+ if not channel_id:
396
+ raise RuntimeError("slack did not return a dm channel")
397
+ await self.client.chat_postMessage(channel=channel_id, text=f"_(the original channel is read-only or unreachable, so I'm DMing you instead)_\n{text}")
398
+ return
399
+ except Exception as e:
400
+ self._record_error("dm_fallback", e, task_id)
401
+ if self.debug is not None:
402
+ await self.debug.system_error("slack", f"channel is read-only/unreachable and no DM fallback landed; message: {text}")
403
+
404
+ async def _post(self, task: Task, text: str, **kwargs) -> None:
405
+ redacted = redactor.redact(to_mrkdwn(text))
406
+ post = {"channel": task.slack_channel_id, "text": redacted, **kwargs}
407
+ if task.slack_team_id == "github":
408
+ if not task.slack_channel_id:
409
+ return
410
+ else:
411
+ post["thread_ts"] = task.slack_thread_ts
412
+ try:
413
+ await self.client.chat_postMessage(**post)
414
+ except Exception as e:
415
+ self._record_error("chat_postMessage", e, task.task_id)
416
+ if isinstance(e, SlackApiError) and e.response.get("error") in UNDELIVERABLE_CHANNEL_ERRORS:
417
+ # system-origin tasks (slack_team_id = "github", slack_user_id = "cli") have no real Slack user to DM — go straight to debug
418
+ fallback_user_id = task.slack_user_id if task.slack_team_id != "github" else ""
419
+ await self._deliver_fallback(fallback_user_id, redacted, task.task_id)
420
+ return
421
+ raise
422
+
423
+ async def progress(self, task: Task, message: str) -> None:
424
+ # agent-declared milestones, rate-limited per task (SLK-006); dropped posts stay in the audit trail
425
+ if self.debug is not None:
426
+ await self.debug.progress(task, message)
427
+ if task.schedule_name:
428
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
429
+ now = time.monotonic()
430
+ if now - self._last_progress.get(task.task_id, 0.0) < self.progress_min_interval_seconds:
431
+ return
432
+ self._last_progress[task.task_id] = now
433
+ await self._post(task, message)
434
+
435
+ async def ensure_debug(self, task: Task) -> None:
436
+ if self.debug is not None and not task.debug_thread_ts:
437
+ opened = await self.debug.open_thread(user_id=task.slack_user_id, channel_id=task.slack_channel_id, request_text=task.request_text, user_profile=self.store.get_slack_user(task.slack_user_id) if self.store else None)
438
+ if opened:
439
+ task.debug_thread_ts, task.debug_permalink = opened
440
+ if self.store is not None:
441
+ self.store.set_fields(task.task_id, debug_thread_ts=task.debug_thread_ts, debug_permalink=task.debug_permalink)
442
+
443
+ async def ack(self, task: Task) -> None:
444
+ await self.ensure_debug(task)
445
+ if task.schedule_name:
446
+ return # scheduled tasks announce themselves in started(); no "On it." quip
447
+ if self.ack_reaction and task.slack_team_id != "github":
448
+ try:
449
+ await self.client.reactions_add(channel=task.slack_channel_id, timestamp=task.slack_message_ts, name="eyes")
450
+ return
451
+ except Exception as e:
452
+ self._record_error("reactions_add", e, task.task_id)
453
+ logger.warning("reactions.add failed for %s — falling back to text ack", task.task_id)
454
+ await self._post(task, "On it.")
455
+
456
+ async def started(self, task: Task) -> None:
457
+ if task.schedule_name:
458
+ details = f"Details: Model: {task.model_alias or 'n/a'} Task ID: {task.task_id}"
459
+ line = f"[{details}]({task.debug_permalink})" if task.debug_permalink else details
460
+ text = f"Scheduled Task Started {task.schedule_name}\n{line}"
461
+ if self.debug is not None:
462
+ await self.debug.classified(task)
463
+ await self.debug.started(task)
464
+ await self._post(task, text, unfurl_links=False, unfurl_media=False)
465
+ return
466
+ label = f"Details - Model: {task.model_alias} - Task ID: {task.task_id}" if task.model_alias else f"Details - Task ID: {task.task_id}"
467
+ task_line = f"[{label}]({task.debug_permalink})" if task.debug_permalink else ""
468
+ if task.persona == "reviewer":
469
+ selected = pick_started_message(self.task_started_messages_path, "reviewer")
470
+ if selected:
471
+ text = selected.replace("{reviewer_name}", self.reviewer_name) + (f"\n{task_line}" if task_line else "")
472
+ else:
473
+ text = f"{self.reviewer_name} started a PR review" + (f"\n{task_line}" if task_line else "")
474
+ else:
475
+ selected = pick_started_message(self.task_started_messages_path, "agent")
476
+ if selected:
477
+ text = selected + (f"\n{task_line}" if task_line else "")
478
+ else:
479
+ model = f" on `{task.model_alias}`" if task.model_alias else ""
480
+ text = f"Task started{model}." + (f"\n{task_line}" if task_line else "")
481
+ if self.debug is not None:
482
+ await self.debug.classified(task)
483
+ await self.debug.started(task)
484
+ await self._post(task, text, unfurl_links=False, unfurl_media=False)
485
+
486
+ async def completed(self, task: Task) -> None:
487
+ self._last_progress.pop(task.task_id, None)
488
+ text = task.reply or task.result_summary or ""
489
+ links: list[str] = []
490
+ seen: set[str] = set()
491
+ if self.store is not None:
492
+ for artifact in self.store.artifacts_for(task.task_id):
493
+ url = str(artifact.get("url") or "")
494
+ kind = artifact.get("kind")
495
+ if kind not in ("pull_request", "jira_issue") or not url or url in text or url in seen:
496
+ continue
497
+ seen.add(url)
498
+ links.append(f"{'PR' if kind == 'pull_request' else 'Jira'}: {url}")
499
+ links_text = "\n".join(links)
500
+ if links_text:
501
+ text = f"{text}\n\n{links_text}" if text else links_text
502
+ if task.persona == "reviewer":
503
+ text = f"*{self.reviewer_name} finished a PR review*\n{text}"
504
+ elif not text:
505
+ text = "*Done*"
506
+ if self.debug is not None:
507
+ await self.debug.completed(task)
508
+ if task.schedule_name:
509
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
510
+ if len(text) > SNIPPET_THRESHOLD and task.slack_team_id != "github":
511
+ try:
512
+ initial_comment = "*Done* — full response attached."
513
+ if links_text:
514
+ initial_comment += f"\n\n{links_text}"
515
+ await self.client.files_upload_v2(
516
+ channel=task.slack_channel_id,
517
+ thread_ts=task.slack_thread_ts,
518
+ filename=f"{task.task_id}-report.md",
519
+ title=f"Task {task.task_id} report",
520
+ content=redactor.redact(task.reply or task.result_summary or ""),
521
+ initial_comment=redactor.redact(to_mrkdwn(initial_comment)),
522
+ )
523
+ return
524
+ except Exception as e:
525
+ self._record_error("files_upload_v2", e, task.task_id)
526
+ logger.warning("report upload failed for %s — falling back to inline post", task.task_id, exc_info=True)
527
+ text = f"*Done*\n{text}"
528
+ if task.slack_team_id == "github" and not task.slack_channel_id:
529
+ return
530
+ await self._post(task, text, unfurl_links=False, unfurl_media=False)
531
+
532
+ async def failed(self, task: Task, error: str) -> None:
533
+ self._last_progress.pop(task.task_id, None)
534
+ if self.debug is not None:
535
+ await self.debug.failed(task, error)
536
+ if task.schedule_name:
537
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
538
+ await self._post(task, f"*Failed*\n{error}")
539
+
540
+ async def blocked(self, task: Task) -> None:
541
+ self._last_progress.pop(task.task_id, None)
542
+ if self.debug is not None:
543
+ await self.debug.blocked(task)
544
+ if task.schedule_name:
545
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
546
+ await self._post(task, f"*Blocked*\n{task.blocked_reason or ''}\nReply in this thread to continue.")
547
+
548
+ async def issue_blocked(self, task: Task, issue: dict) -> None:
549
+ """this task is cancelled and its issue is either reopened as `proposed` or, if it had already opened a PR,
550
+ left tracking that PR instead (#87); points at the issue instead of the now-inert thread reply."""
551
+ self._last_progress.pop(task.task_id, None)
552
+ if self.debug is not None:
553
+ await self.debug.blocked(task)
554
+ if task.schedule_name:
555
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
556
+ reason = task.blocked_reason or "the task could not continue"
557
+ if issue.get("status") == "in_review":
558
+ text = f"*Blocked* — it had already opened {issue['pr_url']}, so I'm tracking that PR on issue #{issue['id']} instead of reopening it.\n{reason}"
559
+ else:
560
+ text = f"*Blocked* — reopened issue #{issue['id']} as `proposed` so you can pick up where this left off.\n{reason}"
561
+ if self.dashboard_url:
562
+ text += f"\n{self.dashboard_url}/issues?issue={issue['id']}"
563
+ await self._post(task, text)
564
+
565
+ async def questions(self, task: Task, questions: str) -> None:
566
+ self._last_progress.pop(task.task_id, None)
567
+ if self.debug is not None:
568
+ await self.debug.questions(task, questions)
569
+ if task.schedule_name:
570
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
571
+ await self._post(task, f"*I need a few answers before I continue*\n{questions}\n\n<@{task.slack_user_id}> reply in this thread with your answers by number and I'll pick the task back up.")
572
+
573
+ async def recovered(self, task: Task) -> None:
574
+ if self.debug is not None:
575
+ await self.debug.recovered(task)
576
+ if task.schedule_name:
577
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
578
+ await self._post(task, "The orchestrator restarted; this task was requeued and will resume.")
579
+
580
+ async def refused(self, task: Task, reason: str) -> None:
581
+ if self.debug is not None:
582
+ if task.classification_json:
583
+ await self.debug.classified(task)
584
+ await self.debug.refused(task, reason)
585
+ if task.schedule_name:
586
+ return # debug feed already threads this into the debug channel; skip the duplicate top-level post
587
+ await self._post(task, f"Can't take this task: {reason}")
588
+
589
+ async def refuse_intake(self, channel_id: str, thread_ts: str, reason: str) -> None:
590
+ # refusals for mentions that never became a task (SLK-010)
591
+ try:
592
+ await self.client.chat_postMessage(channel=channel_id, thread_ts=thread_ts, text=redactor.redact(to_mrkdwn(reason)))
593
+ except Exception as e:
594
+ self._record_error("refuse_intake", e)
595
+ raise
596
+
597
+ async def answer(self, channel_id: str, thread_ts: str | None, text: str) -> None:
598
+ post = {"channel": channel_id, "text": redactor.redact(to_mrkdwn(text)), "unfurl_links": False, "unfurl_media": False}
599
+ if thread_ts is not None:
600
+ post["thread_ts"] = thread_ts
601
+ try:
602
+ await self.client.chat_postMessage(**post)
603
+ except Exception as e:
604
+ self._record_error("answer", e)
605
+ raise
606
+
607
+
608
+ async def build(store: Store, config: Config, bot_token: str) -> tuple[AsyncApp, SlackNotifier]:
609
+ """wire the bolt app: resolve our bot user id, register the mention handler."""
610
+ app = AsyncApp(token=bot_token)
611
+ from taskboy.debug_feed import DebugFeed
612
+
613
+ debug = DebugFeed(app.client, store, config.slack.debug_channel, dashboard_url=config.dashboard.public_url) if config.slack.debug_channel else None
614
+ notifier = SlackNotifier(app.client, config.progress_min_interval_seconds, config.slack.ack_reaction, debug=debug, task_started_messages_path=config.slack.task_started_messages_path, store=store, reviewer_name=config.reviewer.name, dashboard_url=config.dashboard.public_url)
615
+ quick = None
616
+ if (config.raw.get("quick_answer") or {}).get("enabled"):
617
+ from taskboy.quick import QuickAnswer
618
+
619
+ quick = QuickAnswer(store, config, debug=debug)
620
+ try:
621
+ auth = await app.client.auth_test()
622
+ except Exception as e:
623
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "auth_test"})
624
+ if debug is not None:
625
+ await debug.system_error("slack", f"auth.test failed: {e}")
626
+ raise
627
+ bot_user_id = auth["user_id"]
628
+
629
+ @app.event("app_mention")
630
+ async def on_mention(body, event):
631
+ if str(event.get("channel") or "").startswith("D"):
632
+ return
633
+ try:
634
+ task, status = await handle_mention(store, config, notifier, event, body.get("event_id"), bot_user_id, quick=quick, client=app.client)
635
+ logger.info("mention handled: status=%s task=%s", status, task.task_id if task else None)
636
+ except Exception as e:
637
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "handle_mention"})
638
+ if debug is not None:
639
+ await debug.system_error("slack", f"mention handling failed: {e}")
640
+ logger.exception("mention handling failed")
641
+
642
+ @app.event("message")
643
+ async def on_message(body, event):
644
+ if event.get("channel_type") != "im":
645
+ # channel traffic only matters when it answers a blocked task's questions (needs the message.channels event subscription)
646
+ try:
647
+ status = await handle_thread_reply(store, notifier, event, body.get("event_id"), bot_user_id)
648
+ if status != "ignored":
649
+ logger.info("thread reply handled: status=%s", status)
650
+ except Exception as e:
651
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "handle_thread_reply"})
652
+ if debug is not None:
653
+ await debug.system_error("slack", f"thread reply handling failed: {e}")
654
+ logger.exception("thread reply handling failed")
655
+ return
656
+ try:
657
+ dm_event = {**event, "team": event.get("team") or body.get("team_id") or (body.get("team") or {}).get("id") or ""}
658
+ status = await handle_dm(store, config, notifier, dm_event, body.get("event_id"), bot_user_id, quick, app.client)
659
+ logger.info("dm handled: status=%s", status)
660
+ except Exception as e:
661
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "handle_dm"})
662
+ if debug is not None:
663
+ await debug.system_error("slack", f"dm handling failed: {e}")
664
+ logger.exception("dm handling failed")
665
+
666
+ return app, notifier