@urateam/core 0.1.31 → 0.1.33

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 (177) hide show
  1. package/dist/__tests__/agent-stream.test.js +35 -1
  2. package/dist/__tests__/agent-stream.test.js.map +1 -1
  3. package/dist/__tests__/audit-immutability.test.js +7 -1
  4. package/dist/__tests__/audit-immutability.test.js.map +1 -1
  5. package/dist/__tests__/auth-monitor.test.d.ts +2 -0
  6. package/dist/__tests__/auth-monitor.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth-monitor.test.js +253 -0
  8. package/dist/__tests__/auth-monitor.test.js.map +1 -0
  9. package/dist/__tests__/bec-186-repro.test.d.ts +16 -0
  10. package/dist/__tests__/bec-186-repro.test.d.ts.map +1 -0
  11. package/dist/__tests__/bec-186-repro.test.js +223 -0
  12. package/dist/__tests__/bec-186-repro.test.js.map +1 -0
  13. package/dist/__tests__/control-signals.test.d.ts +2 -0
  14. package/dist/__tests__/control-signals.test.d.ts.map +1 -0
  15. package/dist/__tests__/control-signals.test.js +77 -0
  16. package/dist/__tests__/control-signals.test.js.map +1 -0
  17. package/dist/__tests__/db-migrations.test.d.ts +2 -0
  18. package/dist/__tests__/db-migrations.test.d.ts.map +1 -0
  19. package/dist/__tests__/db-migrations.test.js +237 -0
  20. package/dist/__tests__/db-migrations.test.js.map +1 -0
  21. package/dist/__tests__/executor-issue-id.test.js +2 -0
  22. package/dist/__tests__/executor-issue-id.test.js.map +1 -1
  23. package/dist/__tests__/pm-slack-interface.test.js +45 -0
  24. package/dist/__tests__/pm-slack-interface.test.js.map +1 -1
  25. package/dist/__tests__/pm-triage.test.js +101 -0
  26. package/dist/__tests__/pm-triage.test.js.map +1 -1
  27. package/dist/__tests__/post-fanout-comments.test.js +36 -0
  28. package/dist/__tests__/post-fanout-comments.test.js.map +1 -1
  29. package/dist/__tests__/preflight-claude-auth.test.d.ts +2 -0
  30. package/dist/__tests__/preflight-claude-auth.test.d.ts.map +1 -0
  31. package/dist/__tests__/preflight-claude-auth.test.js +36 -0
  32. package/dist/__tests__/preflight-claude-auth.test.js.map +1 -0
  33. package/dist/__tests__/resolve-claude-auth.test.d.ts +2 -0
  34. package/dist/__tests__/resolve-claude-auth.test.d.ts.map +1 -0
  35. package/dist/__tests__/resolve-claude-auth.test.js +129 -0
  36. package/dist/__tests__/resolve-claude-auth.test.js.map +1 -0
  37. package/dist/__tests__/stage-models.test.js +4 -0
  38. package/dist/__tests__/stage-models.test.js.map +1 -1
  39. package/dist/__tests__/util-linear.test.d.ts +10 -0
  40. package/dist/__tests__/util-linear.test.d.ts.map +1 -0
  41. package/dist/__tests__/util-linear.test.js +244 -0
  42. package/dist/__tests__/util-linear.test.js.map +1 -0
  43. package/dist/audit/events.d.ts +37 -0
  44. package/dist/audit/events.d.ts.map +1 -1
  45. package/dist/audit/events.js +53 -0
  46. package/dist/audit/events.js.map +1 -1
  47. package/dist/db/client.d.ts.map +1 -1
  48. package/dist/db/client.js +8 -0
  49. package/dist/db/client.js.map +1 -1
  50. package/dist/db/migrations/postgres/014_missing_indexes.sql +28 -0
  51. package/dist/db/migrations/sqlite/013_missing_indexes.sql +28 -0
  52. package/dist/executor/agent-stream.d.ts +16 -0
  53. package/dist/executor/agent-stream.d.ts.map +1 -1
  54. package/dist/executor/agent-stream.js +43 -1
  55. package/dist/executor/agent-stream.js.map +1 -1
  56. package/dist/executor/auth-check.d.ts +39 -0
  57. package/dist/executor/auth-check.d.ts.map +1 -1
  58. package/dist/executor/auth-check.js +31 -0
  59. package/dist/executor/auth-check.js.map +1 -1
  60. package/dist/executor/auth-monitor.d.ts +40 -0
  61. package/dist/executor/auth-monitor.d.ts.map +1 -0
  62. package/dist/executor/auth-monitor.js +114 -0
  63. package/dist/executor/auth-monitor.js.map +1 -0
  64. package/dist/executor/executor.d.ts.map +1 -1
  65. package/dist/executor/executor.js +7 -2
  66. package/dist/executor/executor.js.map +1 -1
  67. package/dist/executor/index.d.ts +2 -0
  68. package/dist/executor/index.d.ts.map +1 -1
  69. package/dist/executor/index.js +2 -0
  70. package/dist/executor/index.js.map +1 -1
  71. package/dist/executor/review/post-fanout-comments.d.ts +8 -0
  72. package/dist/executor/review/post-fanout-comments.d.ts.map +1 -1
  73. package/dist/executor/review/post-fanout-comments.js +23 -3
  74. package/dist/executor/review/post-fanout-comments.js.map +1 -1
  75. package/dist/index.d.ts +3 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +3 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/notifier/composite.d.ts +1 -0
  80. package/dist/notifier/composite.d.ts.map +1 -1
  81. package/dist/notifier/composite.js +3 -0
  82. package/dist/notifier/composite.js.map +1 -1
  83. package/dist/notifier/linear.d.ts +1 -0
  84. package/dist/notifier/linear.d.ts.map +1 -1
  85. package/dist/notifier/linear.js +7 -0
  86. package/dist/notifier/linear.js.map +1 -1
  87. package/dist/pipeline/control-signals.d.ts +49 -0
  88. package/dist/pipeline/control-signals.d.ts.map +1 -0
  89. package/dist/pipeline/control-signals.js +93 -0
  90. package/dist/pipeline/control-signals.js.map +1 -0
  91. package/dist/pipeline/feedback-pipeline.d.ts +140 -0
  92. package/dist/pipeline/feedback-pipeline.d.ts.map +1 -0
  93. package/dist/pipeline/feedback-pipeline.js +427 -0
  94. package/dist/pipeline/feedback-pipeline.js.map +1 -0
  95. package/dist/pipeline/index.d.ts +1 -0
  96. package/dist/pipeline/index.d.ts.map +1 -1
  97. package/dist/pipeline/index.js +1 -0
  98. package/dist/pipeline/index.js.map +1 -1
  99. package/dist/pipeline/runner.d.ts +49 -33
  100. package/dist/pipeline/runner.d.ts.map +1 -1
  101. package/dist/pipeline/runner.js +143 -350
  102. package/dist/pipeline/runner.js.map +1 -1
  103. package/dist/pm/actions/promote.d.ts.map +1 -1
  104. package/dist/pm/actions/promote.js +15 -11
  105. package/dist/pm/actions/promote.js.map +1 -1
  106. package/dist/pm/actions/recover-stuck.d.ts.map +1 -1
  107. package/dist/pm/actions/recover-stuck.js +9 -5
  108. package/dist/pm/actions/recover-stuck.js.map +1 -1
  109. package/dist/pm/actions/triage.d.ts.map +1 -1
  110. package/dist/pm/actions/triage.js +67 -1
  111. package/dist/pm/actions/triage.js.map +1 -1
  112. package/dist/pm/linear-helpers.d.ts +10 -0
  113. package/dist/pm/linear-helpers.d.ts.map +1 -1
  114. package/dist/pm/linear-helpers.js +13 -21
  115. package/dist/pm/linear-helpers.js.map +1 -1
  116. package/dist/pm/pause-state.d.ts +29 -0
  117. package/dist/pm/pause-state.d.ts.map +1 -0
  118. package/dist/pm/pause-state.js +34 -0
  119. package/dist/pm/pause-state.js.map +1 -0
  120. package/dist/pm/scheduler.d.ts.map +1 -1
  121. package/dist/pm/scheduler.js +19 -0
  122. package/dist/pm/scheduler.js.map +1 -1
  123. package/dist/pm/slack-bulk.d.ts +34 -0
  124. package/dist/pm/slack-bulk.d.ts.map +1 -0
  125. package/dist/pm/slack-bulk.js +110 -0
  126. package/dist/pm/slack-bulk.js.map +1 -0
  127. package/dist/pm/slack-commands.d.ts +101 -0
  128. package/dist/pm/slack-commands.d.ts.map +1 -0
  129. package/dist/pm/slack-commands.js +309 -0
  130. package/dist/pm/slack-commands.js.map +1 -0
  131. package/dist/pm/slack-helpers.d.ts +10 -0
  132. package/dist/pm/slack-helpers.d.ts.map +1 -1
  133. package/dist/pm/slack-helpers.js +32 -0
  134. package/dist/pm/slack-helpers.js.map +1 -1
  135. package/dist/pm/slack-interface.d.ts +32 -58
  136. package/dist/pm/slack-interface.d.ts.map +1 -1
  137. package/dist/pm/slack-interface.js +150 -320
  138. package/dist/pm/slack-interface.js.map +1 -1
  139. package/dist/rbac/matrix.d.ts +2 -0
  140. package/dist/rbac/matrix.d.ts.map +1 -1
  141. package/dist/rbac/matrix.js +2 -0
  142. package/dist/rbac/matrix.js.map +1 -1
  143. package/dist/release-manager/index.d.ts +2 -0
  144. package/dist/release-manager/index.d.ts.map +1 -1
  145. package/dist/release-manager/index.js +2 -0
  146. package/dist/release-manager/index.js.map +1 -1
  147. package/dist/release-manager/release-helpers.d.ts +112 -0
  148. package/dist/release-manager/release-helpers.d.ts.map +1 -0
  149. package/dist/release-manager/release-helpers.js +164 -0
  150. package/dist/release-manager/release-helpers.js.map +1 -0
  151. package/dist/release-manager/release-tick.d.ts +101 -0
  152. package/dist/release-manager/release-tick.d.ts.map +1 -0
  153. package/dist/release-manager/release-tick.js +374 -0
  154. package/dist/release-manager/release-tick.js.map +1 -0
  155. package/dist/release-manager/scheduler.d.ts +28 -3
  156. package/dist/release-manager/scheduler.d.ts.map +1 -1
  157. package/dist/release-manager/scheduler.js +41 -417
  158. package/dist/release-manager/scheduler.js.map +1 -1
  159. package/dist/server.d.ts.map +1 -1
  160. package/dist/server.js +10 -0
  161. package/dist/server.js.map +1 -1
  162. package/dist/sync/gh-linear-sync.d.ts.map +1 -1
  163. package/dist/sync/gh-linear-sync.js +2 -2
  164. package/dist/sync/gh-linear-sync.js.map +1 -1
  165. package/dist/types.d.ts +22 -4
  166. package/dist/types.d.ts.map +1 -1
  167. package/dist/types.js +10 -0
  168. package/dist/types.js.map +1 -1
  169. package/dist/util/linear.d.ts +70 -0
  170. package/dist/util/linear.d.ts.map +1 -0
  171. package/dist/util/linear.js +108 -0
  172. package/dist/util/linear.js.map +1 -0
  173. package/dist/webhook/github-handler.d.ts +7 -1
  174. package/dist/webhook/github-handler.d.ts.map +1 -1
  175. package/dist/webhook/github-handler.js +82 -38
  176. package/dist/webhook/github-handler.js.map +1 -1
  177. package/package.json +1 -1
@@ -7,19 +7,37 @@
7
7
  *
8
8
  * Outbound helpers (call from PM scheduler):
9
9
  * notifyAssigned, notifySkipped, askForClarification, postDailySummary
10
+ *
11
+ * This file retains: types, request parsing, Slack signature verification, the
12
+ * notifier class, and the Hono router factory. Command execution logic was
13
+ * extracted to `slack-commands.ts` (BEC-195) and bulk-create analysis to
14
+ * `slack-bulk.ts` (BEC-195). Both are re-exported here for backward
15
+ * compatibility so existing import sites do not need to change.
10
16
  */
11
17
  import { Hono } from "hono";
12
18
  import { createLogger } from "../logger.js";
13
19
  import { sanitize } from "../executor/prompt/sanitizer.js";
14
20
  import { parseJsonObject } from "../executor/agent-stream.js";
15
21
  import { makeCallClaude, makeCallClaudeSonnet } from "./call-claude.js";
16
- import { postSlackMessage } from "./slack-helpers.js";
17
- import { createLazyLinearClient } from "./linear-helpers.js";
22
+ import { postSlackMessage, reactToSlackMessage } from "./slack-helpers.js";
23
+ import { executePmCommand } from "./slack-commands.js";
18
24
  const log = createLogger({ component: "PmAgent:slack-interface" });
19
25
  // ---------------------------------------------------------------------------
26
+ // Re-exports for backward compatibility
27
+ // ---------------------------------------------------------------------------
28
+ // Consumers that previously imported directly from slack-interface.ts continue
29
+ // to work without changing their import statements.
30
+ export { isPmPaused, setPmPaused } from "./pause-state.js";
31
+ export { analyzeBulkCreateRequest } from "./slack-bulk.js";
32
+ export { executePmCommand } from "./slack-commands.js";
33
+ // ---------------------------------------------------------------------------
20
34
  // Module-level constants
21
35
  // ---------------------------------------------------------------------------
22
- /** Valid PM command type names — kept in sync with the PmCommand union type. */
36
+ /**
37
+ * Regex that matches Slack user-mention tokens like `<@U01ABC>`.
38
+ * Used to strip @-mentions from incoming Events API messages before NL processing.
39
+ */
40
+ const SLACK_MENTION_RE = /<@[A-Z0-9]+>/g;
23
41
  const VALID_PM_COMMAND_TYPES = [
24
42
  "prioritize",
25
43
  "create",
@@ -28,18 +46,17 @@ const VALID_PM_COMMAND_TYPES = [
28
46
  "pause",
29
47
  "resume",
30
48
  "assign",
49
+ "cancel",
50
+ "stop",
51
+ "halt",
31
52
  "unknown",
32
53
  ];
33
- /** Linear workflow state name for the Triage column. */
34
- const LINEAR_STATE_TRIAGE = "Triage";
35
- /** Linear workflow state name for the Todo column. */
36
- const LINEAR_STATE_TODO = "Todo";
37
- /** Linear label name that triggers the auto-implement pipeline. */
38
- const LINEAR_LABEL_AUTO_IMPLEMENT = "auto-implement";
39
- /** Valid numeric priority values accepted by Linear (1=Urgent … 4=Low). */
40
- const VALID_PRIORITIES = [1, 2, 3, 4];
41
- /** Default priority used when a generated issue omits or has an invalid value. */
42
- const DEFAULT_PRIORITY = 3;
54
+ // Compile-time exhaustiveness guard: TypeScript reports an error on the next line
55
+ // when a new `PmCommand` variant is added to `slack-commands.ts` without also
56
+ // adding it to `VALID_PM_COMMAND_TYPES` above. The conditional type resolves to
57
+ // `true` when the array is complete and `false` otherwise; assigning `true` to a
58
+ // `false`-typed slot triggers "Type 'boolean' is not assignable to type 'false'".
59
+ const _cmdExhaustiveCheck = true;
43
60
  /** Lazy singleton for the `crypto` built-in — avoids repeated dynamic import on every Slack request. */
44
61
  let _cryptoModule = null;
45
62
  async function getCrypto() {
@@ -55,28 +72,6 @@ async function getCrypto() {
55
72
  return _cryptoModule;
56
73
  }
57
74
  // ---------------------------------------------------------------------------
58
- // Shared pause state — single-process only; use Redis for multi-process
59
- // ---------------------------------------------------------------------------
60
- let paused = false;
61
- /**
62
- * Returns `true` if the PM Agent is currently paused.
63
- *
64
- * Pause is active when EITHER of the following is true (OR logic):
65
- * - `process.env.PM_AGENT_PAUSED === "true"` — env-var path for no-Slack incident
66
- * response. Toggling requires a container restart (env vars are read at each
67
- * tick invocation, not at module load time).
68
- * - `setPmPaused(true)` has been called via the Slack `/pm pause` command.
69
- *
70
- * The env-var takes priority: setting `PM_AGENT_PAUSED=true` keeps the agent
71
- * paused even if `setPmPaused(false)` is subsequently called via Slack.
72
- */
73
- export function isPmPaused() {
74
- return process.env.PM_AGENT_PAUSED === "true" || paused;
75
- }
76
- export function setPmPaused(value) {
77
- paused = value;
78
- }
79
- // ---------------------------------------------------------------------------
80
75
  // Slack request verification
81
76
  // ---------------------------------------------------------------------------
82
77
  /**
@@ -165,6 +160,17 @@ export function parsePmCommand(text) {
165
160
  return { type: "pause" };
166
161
  if (/^resume$/i.test(trimmed))
167
162
  return { type: "resume" };
163
+ if (/^halt$/i.test(trimmed))
164
+ return { type: "halt" };
165
+ // `cancel <runId>` and `stop <runId>` — runId is a nanoid (URL-safe chars,
166
+ // 8+ in practice). Not validated against the DB here; the executor reports
167
+ // "not found" for invalid ids so the operator sees a clear failure.
168
+ const cancelMatch = trimmed.match(/^cancel\s+([A-Za-z0-9_-]{6,})$/i);
169
+ if (cancelMatch)
170
+ return { type: "cancel", runId: cancelMatch[1] };
171
+ const stopMatch = trimmed.match(/^stop\s+([A-Za-z0-9_-]{6,})$/i);
172
+ if (stopMatch)
173
+ return { type: "stop", runId: stopMatch[1] };
168
174
  return { type: "unknown", original: text };
169
175
  }
170
176
  // ---------------------------------------------------------------------------
@@ -176,7 +182,7 @@ export function parsePmCommand(text) {
176
182
  */
177
183
  export async function interpretNaturalLanguage(message, callClaude) {
178
184
  const safe = sanitize(message);
179
- const prompt = `You are a PM Agent assistant. The following Slack message was sent to you:\n\n"${safe}"\n\nClassify it as exactly ONE of these JSON responses (no other text):\n{"type":"prioritize","issueId":"<id>"}\n{"type":"create","title":"<t>","description":"<d>"}\n{"type":"bulk_create","request":"<original request>"}\n{"type":"status"}\n{"type":"pause"}\n{"type":"resume"}\n{"type":"assign","issueId":"<id>"}\n{"type":"unknown","original":"${safe}"}\n\nRules:\n- Issue IDs look like BEC-25, ENG-42, etc.\n- "more urgent" / "higher priority" → prioritize\n- "add", "open a ticket", "create" → create (single issue with clear title)\n- "create issues for", "find gaps and create", "generate issues", "analyze and create multiple", "create tickets for all" → bulk_create (multiple issues from analysis)\n- "what's running", "show queue" → status\n- "stop", "pause" → pause\n- "start again", "unpause", "resume" → resume\n- "move to todo", "assign" → assign\n- Use bulk_create when the request implies analysis or generating multiple issues, not a single specific issue\n\nRespond ONLY with the JSON object.`;
185
+ const prompt = `You are a PM Agent assistant. The following Slack message was sent to you:\n\n"${safe}"\n\nClassify it as exactly ONE of these JSON responses (no other text):\n{"type":"prioritize","issueId":"<id>"}\n{"type":"create","title":"<t>","description":"<d>"}\n{"type":"bulk_create","request":"<original request>"}\n{"type":"status"}\n{"type":"pause"}\n{"type":"resume"}\n{"type":"assign","issueId":"<id>"}\n{"type":"cancel","runId":"<runId>"}\n{"type":"stop","runId":"<runId>"}\n{"type":"halt"}\n{"type":"unknown","original":"${safe}"}\n\nRules:\n- Issue IDs look like BEC-25, ENG-42, etc. Run IDs are longer alphanumeric (nanoid).\n- "more urgent" / "higher priority" → prioritize\n- "add", "open a ticket", "create" → create (single issue with clear title)\n- "create issues for", "find gaps and create", "generate issues", "analyze and create multiple", "create tickets for all" → bulk_create (multiple issues from analysis)\n- "what's running", "show queue" → status\n- "pause the agent" / "stop assigning" → pause\n- "start again", "unpause", "resume" → resume\n- "move to todo", "assign" → assign\n- "cancel run <id>", "kill run <id>", "abort run <id>" → cancel (mid-stream interrupt)\n- "stop run <id>", "graceful stop <id>", "wind down run <id>" → stop (finish current stage, then quit)\n- "halt everything", "stop everything", "emergency stop", "pause all and cancel" → halt\n- Use bulk_create when the request implies analysis or generating multiple issues, not a single specific issue\n- Treat \`pause\` (single-word, no runId) as the PM-agent pause, NOT halt. Halt is the explicit "halt the whole container" intent.\n\nRespond ONLY with the JSON object.`;
180
186
  try {
181
187
  const raw = await callClaude(prompt);
182
188
  const parsed = parseJsonObject(raw);
@@ -193,199 +199,6 @@ export async function interpretNaturalLanguage(message, callClaude) {
193
199
  return { type: "unknown", original: message };
194
200
  }
195
201
  }
196
- /**
197
- * Executes a parsed `PmCommand` against Linear and returns a human-readable
198
- * response string suitable for posting back to Slack.
199
- */
200
- export async function executePmCommand(cmd, deps) {
201
- const { getClient: getLinear } = createLazyLinearClient(deps.linearApiKey);
202
- /**
203
- * Searches Linear for an issue by its identifier (e.g. "BEC-25") and returns
204
- * the first match, or `null` when not found. Shared by prioritize + assign.
205
- */
206
- async function findIssueByIdentifier(linear, issueId) {
207
- const results = await linear.searchIssues(issueId);
208
- return results.nodes?.[0] ?? null;
209
- }
210
- switch (cmd.type) {
211
- case "status": {
212
- const state = isPmPaused() ? "⏸ *Paused*" : "▶️ *Running*";
213
- return `PM Agent is ${state}.\nUse \`/pm pause\` or \`/pm resume\` to control autonomous assignment.`;
214
- }
215
- case "pause": {
216
- setPmPaused(true);
217
- log.info("PM Agent paused via Slack");
218
- return "⏸ PM Agent autonomous assignment has been *paused*. Use `/pm resume` to restart.";
219
- }
220
- case "resume": {
221
- setPmPaused(false);
222
- log.info("PM Agent resumed via Slack");
223
- return "▶️ PM Agent autonomous assignment has been *resumed*.";
224
- }
225
- case "prioritize": {
226
- if (!deps.linearApiKey) {
227
- return `⚠️ No Linear API key configured — cannot prioritize *${cmd.issueId}*.`;
228
- }
229
- try {
230
- const linear = await getLinear();
231
- if (!linear)
232
- return `⚠️ No Linear API key configured — cannot prioritize *${cmd.issueId}*.`;
233
- const issue = await findIssueByIdentifier(linear, cmd.issueId);
234
- if (!issue)
235
- return `⚠️ Issue *${cmd.issueId}* not found in Linear.`;
236
- // updateIssue and createComment are independent — run in parallel
237
- await Promise.all([
238
- linear.updateIssue(issue.id, { priority: 1 }),
239
- linear.createComment({
240
- issueId: issue.id,
241
- body: "🤖 **PM Agent** — Bumped to top of queue via Slack command.",
242
- }),
243
- ]);
244
- log.info({ issueId: cmd.issueId }, "prioritized via Slack");
245
- return `✅ *${cmd.issueId}* has been bumped to top priority (Urgent).`;
246
- }
247
- catch (err) {
248
- log.error({ err, issueId: cmd.issueId }, "prioritize failed");
249
- return `❌ Failed to prioritize *${cmd.issueId}*: ${err.message}`;
250
- }
251
- }
252
- case "assign": {
253
- if (!deps.linearApiKey) {
254
- return `⚠️ No Linear API key configured — cannot assign *${cmd.issueId}*.`;
255
- }
256
- try {
257
- const linear = await getLinear();
258
- if (!linear)
259
- return `⚠️ No Linear API key configured — cannot assign *${cmd.issueId}*.`;
260
- const issue = await findIssueByIdentifier(linear, cmd.issueId);
261
- if (!issue)
262
- return `⚠️ Issue *${cmd.issueId}* not found in Linear.`;
263
- const team = await issue.team;
264
- const allStates = await linear.workflowStates({
265
- filter: { team: { id: { eq: team?.id } } },
266
- first: 50,
267
- });
268
- const todoState = allStates.nodes?.find((s) => s.name === LINEAR_STATE_TODO);
269
- if (!todoState)
270
- return `⚠️ No "${LINEAR_STATE_TODO}" state found for *${cmd.issueId}*'s team.`;
271
- await linear.updateIssue(issue.id, { stateId: todoState.id });
272
- await linear.createComment({
273
- issueId: issue.id,
274
- body: "🤖 **PM Agent** — Manually assigned to Todo via Slack command.",
275
- });
276
- log.info({ issueId: cmd.issueId }, "manually assigned to Todo via Slack");
277
- return `✅ *${cmd.issueId}* has been moved to Todo.`;
278
- }
279
- catch (err) {
280
- log.error({ err, issueId: cmd.issueId }, "assign failed");
281
- return `❌ Failed to assign *${cmd.issueId}*: ${err.message}`;
282
- }
283
- }
284
- case "create": {
285
- if (!deps.linearApiKey) {
286
- return `⚠️ No Linear API key configured — cannot create issue.`;
287
- }
288
- if (!deps.teamIds || deps.teamIds.length === 0) {
289
- return `⚠️ No team IDs configured — cannot create issue.`;
290
- }
291
- try {
292
- const linear = await getLinear();
293
- if (!linear)
294
- return `⚠️ No Linear API key configured — cannot create issue.`;
295
- const created = await linear.createIssue({
296
- teamId: deps.teamIds[0],
297
- title: cmd.title,
298
- description: cmd.description || undefined,
299
- });
300
- const issue = await created.issue;
301
- const url = issue?.url ?? "";
302
- log.info({ title: cmd.title, issueId: issue?.identifier }, "issue created via Slack");
303
- return `✅ Created <${url}|${issue?.identifier ?? "new issue"}>: *${cmd.title}*`;
304
- }
305
- catch (err) {
306
- log.error({ err, title: cmd.title }, "create issue failed");
307
- return `❌ Failed to create issue: ${err.message}`;
308
- }
309
- }
310
- case "bulk_create": {
311
- if (!deps.linearApiKey) {
312
- return `⚠️ No Linear API key configured — cannot create issues.`;
313
- }
314
- if (!deps.teamIds || deps.teamIds.length === 0) {
315
- return `⚠️ No team IDs configured — cannot create issues.`;
316
- }
317
- if (!deps.callClaudeSonnet) {
318
- return `⚠️ Bulk create requires a Sonnet model caller — not configured.`;
319
- }
320
- try {
321
- const specs = await analyzeBulkCreateRequest(cmd.request, deps.callClaudeSonnet);
322
- if (specs.length === 0) {
323
- return `🤔 Could not generate any issues from your request. Try being more specific.`;
324
- }
325
- const linear = await getLinear();
326
- if (!linear)
327
- return `⚠️ No Linear API key configured — cannot create issues.`;
328
- // Resolve the Triage state and auto-implement label IDs
329
- const teamId = deps.teamIds[0];
330
- const [allStatesRes, allLabelsRes] = await Promise.all([
331
- linear.workflowStates({ filter: { team: { id: { eq: teamId } } }, first: 50 }),
332
- linear.issueLabels({ first: 100 }),
333
- ]);
334
- const triageState = allStatesRes.nodes?.find((s) => s.name === LINEAR_STATE_TRIAGE);
335
- const labelMap = new Map();
336
- for (const label of allLabelsRes.nodes ?? []) {
337
- labelMap.set(label.name.toLowerCase(), label.id);
338
- }
339
- const autoImplementLabelId = labelMap.get(LINEAR_LABEL_AUTO_IMPLEMENT);
340
- // Build all payloads first, then create all issues in parallel
341
- const payloads = specs.map((spec) => {
342
- const descWithCriteria = spec.acceptanceCriteria.length > 0
343
- ? `${spec.description}\n\n**Acceptance Criteria:**\n${spec.acceptanceCriteria.map((c) => `- [ ] ${c}`).join("\n")}`
344
- : spec.description;
345
- const payload = {
346
- teamId,
347
- title: spec.title,
348
- description: descWithCriteria || undefined,
349
- priority: spec.priority,
350
- };
351
- if (triageState)
352
- payload.stateId = triageState.id;
353
- if (autoImplementLabelId)
354
- payload.labelIds = [autoImplementLabelId];
355
- return payload;
356
- });
357
- const results = await Promise.all(payloads.map((p) => linear.createIssue(p)));
358
- const issueObjects = await Promise.all(results.map((r) => r.issue));
359
- const created = [];
360
- for (let i = 0; i < issueObjects.length; i++) {
361
- const issue = issueObjects[i];
362
- if (issue) {
363
- const title = specs[i].title;
364
- created.push({ identifier: issue.identifier ?? "", url: issue.url ?? "", title });
365
- log.info({ issueId: issue.identifier, title }, "bulk issue created via Slack");
366
- }
367
- }
368
- if (created.length === 0) {
369
- return `❌ Failed to create any issues.`;
370
- }
371
- const lines = [`✅ Created ${created.length} issue${created.length === 1 ? "" : "s"}:`];
372
- for (const issue of created) {
373
- const link = issue.url ? `<${issue.url}|${issue.identifier}>` : `*${issue.identifier}*`;
374
- lines.push(`• ${link}: *${issue.title}*`);
375
- }
376
- return lines.join("\n");
377
- }
378
- catch (err) {
379
- log.error({ err, request: cmd.request }, "bulk create failed");
380
- return `❌ Failed to create issues: ${err.message}`;
381
- }
382
- }
383
- case "unknown":
384
- return `🤔 I didn't understand that. Try:\n• \`/pm status\`\n• \`/pm prioritize BEC-25\`\n• \`/pm create "title" "description"\`\n• \`/pm assign BEC-13\`\n• \`/pm pause\` / \`/pm resume\``;
385
- default:
386
- return `Unknown command.`;
387
- }
388
- }
389
202
  // ---------------------------------------------------------------------------
390
203
  // Outbound notifications (PM Agent → Slack)
391
204
  // ---------------------------------------------------------------------------
@@ -401,18 +214,18 @@ export class SlackInterfaceNotifier {
401
214
  const urlPart = n.issueUrl ? `<${n.issueUrl}|${n.issueId}>` : `*${n.issueId}*`;
402
215
  const text = `🤖 *PM Agent assigned* ${urlPart}: ${n.issueTitle}\n` +
403
216
  `*Reasoning:* ${n.reasoning}`;
404
- await this.postMessage({ channel: this.channelId, blocks: [{ type: "section", text: { type: "mrkdwn", text } }] });
217
+ await this.postMarkdownMessage(text);
405
218
  }
406
219
  /** Called when PM Agent skips/deprioritizes an issue. */
407
220
  async notifySkipped(n) {
408
221
  const text = `⏭ *PM Agent skipped* *${n.issueId}*: ${n.issueTitle}\n` +
409
222
  `*Reason:* ${n.reasoning}`;
410
- await this.postMessage({ channel: this.channelId, blocks: [{ type: "section", text: { type: "mrkdwn", text } }] });
223
+ await this.postMarkdownMessage(text);
411
224
  }
412
225
  /** Ask a human for input when priority is ambiguous. */
413
226
  async askForClarification(question) {
414
227
  const text = `❓ *PM Agent needs your input:*\n${question}`;
415
- await this.postMessage({ channel: this.channelId, blocks: [{ type: "section", text: { type: "mrkdwn", text } }] });
228
+ await this.postMarkdownMessage(text);
416
229
  }
417
230
  /** Post a daily summary of assigned, completed, and blocked issues. */
418
231
  async postDailySummary(entries, date) {
@@ -444,6 +257,17 @@ export class SlackInterfaceNotifier {
444
257
  blocks: [{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }],
445
258
  });
446
259
  }
260
+ /**
261
+ * Posts a single Slack Block Kit section containing mrkdwn-formatted `text`
262
+ * to the configured channel. All three simple notification methods delegate
263
+ * here to avoid repeating the identical blocks structure.
264
+ */
265
+ async postMarkdownMessage(text) {
266
+ await this.postMessage({
267
+ channel: this.channelId,
268
+ blocks: [{ type: "section", text: { type: "mrkdwn", text } }],
269
+ });
270
+ }
447
271
  async postMessage(payload) {
448
272
  await postSlackMessage(this.botToken, payload);
449
273
  }
@@ -468,11 +292,18 @@ export function createSlackInterface(config) {
468
292
  const notifier = new SlackInterfaceNotifier(config);
469
293
  const callClaude = config.callClaude ?? makeCallClaude();
470
294
  const callClaudeSonnet = config.callClaudeSonnet ?? makeCallClaudeSonnet();
471
- const executorDeps = {
295
+ const baseExecutorDeps = {
472
296
  linearApiKey: config.linearApiKey,
473
297
  teamIds: config.teamIds,
474
298
  callClaudeSonnet,
299
+ runner: config.runner,
300
+ db: config.db,
475
301
  };
302
+ // Per-request deps thread the Slack user id through for audit attribution.
303
+ const withSlackUser = (slackUserId) => ({
304
+ ...baseExecutorDeps,
305
+ slackUserId,
306
+ });
476
307
  // Helper: verify Slack signature and return 401 on failure
477
308
  async function checkSignature(c) {
478
309
  const rawBody = await c.req.text();
@@ -512,18 +343,44 @@ export function createSlackInterface(config) {
512
343
  }
513
344
  return c.json({ response_type: r.responseType, text: r.text });
514
345
  }
515
- // Default: /pm path (preserves existing behavior).
346
+ // Default: /pm path. When a response_url is present (the normal case in
347
+ // production), ack immediately with a :thinking_face: line and post the
348
+ // real reply asynchronously — this lets slow commands (bulk_create,
349
+ // anything that hits Linear/Sonnet) take their time without tripping
350
+ // Slack's 3s slash-command timeout. When no response_url is available
351
+ // (rare; only legacy/non-production callers), fall back to the
352
+ // synchronous path so the operator still sees a result.
353
+ if (responseUrl) {
354
+ void (async () => {
355
+ try {
356
+ let cmd = parsePmCommand(commandText);
357
+ if (cmd.type === "unknown" && commandText.length > 0) {
358
+ cmd = await interpretNaturalLanguage(commandText, callClaude);
359
+ }
360
+ const replyText = await executePmCommand(cmd, withSlackUser(userId));
361
+ await postToResponseUrl(responseUrl, replyText);
362
+ }
363
+ catch (err) {
364
+ log.error({ err }, "async slash command processing failed");
365
+ try {
366
+ await postToResponseUrl(responseUrl, ":warning: Something went wrong while processing that command. Check the urateam logs.");
367
+ }
368
+ catch {
369
+ // already logged above
370
+ }
371
+ }
372
+ })();
373
+ return c.json({
374
+ response_type: "ephemeral",
375
+ text: ":thinking_face: Working on it…",
376
+ });
377
+ }
378
+ // No response_url — process synchronously.
516
379
  let cmd = parsePmCommand(commandText);
517
- // Fall back to NL interpretation if command is unknown
518
380
  if (cmd.type === "unknown" && commandText.length > 0) {
519
381
  cmd = await interpretNaturalLanguage(commandText, callClaude);
520
382
  }
521
- const replyText = await executePmCommand(cmd, executorDeps);
522
- // If a response_url is provided, post back asynchronously
523
- if (responseUrl) {
524
- postToResponseUrl(responseUrl, replyText).catch((err) => log.error({ err }, "failed to post to Slack response_url"));
525
- }
526
- // Immediate acknowledgement (required within 3s)
383
+ const replyText = await executePmCommand(cmd, withSlackUser(userId));
527
384
  return c.json({ response_type: "ephemeral", text: replyText });
528
385
  });
529
386
  // ------------------------------------------------------------------
@@ -562,13 +419,23 @@ export function createSlackInterface(config) {
562
419
  if (event.bot_id || event.subtype === "bot_message") {
563
420
  return c.json({ ok: true });
564
421
  }
565
- const messageText = (event.text ?? "").replace(/<@[A-Z0-9]+>/g, "").trim();
422
+ const messageText = (event.text ?? "").replace(SLACK_MENTION_RE, "").trim();
566
423
  if (!messageText)
567
424
  return c.json({ ok: true });
568
425
  log.info({ messageText }, "received Slack message event");
569
- // Process asynchronously acknowledge immediately
570
- processMessageAsync(messageText, event.channel ?? config.channelId, config.botToken, callClaude, executorDeps)
571
- .catch((err) => log.error({ err }, "async message processing failed"));
426
+ // UX: react with :thinking_face: immediately so the user sees the bot
427
+ // picked up the mention. processMessageAsync swaps it for
428
+ // :white_check_mark: on success or :warning: on failure. The reaction
429
+ // is best-effort — failure logs but never blocks the actual work.
430
+ const channelForReact = event.channel ?? config.channelId;
431
+ const ts = typeof event.ts === "string" ? event.ts : null;
432
+ if (ts) {
433
+ void reactToSlackMessage(config.botToken, channelForReact, ts, "thinking_face");
434
+ }
435
+ // Process asynchronously — acknowledge immediately. Use the Slack
436
+ // event's user id for audit attribution (mentions in the PM channel).
437
+ const eventUserId = typeof event.user === "string" ? event.user : "";
438
+ processMessageAsync(messageText, channelForReact, config.botToken, callClaude, withSlackUser(eventUserId), ts).catch((err) => log.error({ err }, "async message processing failed"));
572
439
  }
573
440
  }
574
441
  return c.json({ ok: true });
@@ -578,77 +445,6 @@ export function createSlackInterface(config) {
578
445
  // ---------------------------------------------------------------------------
579
446
  // Internal helpers
580
447
  // ---------------------------------------------------------------------------
581
- /**
582
- * Uses a capable Claude model (Sonnet) to analyze a bulk create request and
583
- * produce a structured list of issue specifications.
584
- */
585
- export async function analyzeBulkCreateRequest(request, callClaudeSonnet) {
586
- const safe = sanitize(request);
587
- const prompt = `You are a software PM Agent. A user asked: "${safe}"\n\n` +
588
- `Analyze this request and generate a list of concrete, actionable software issues to create.\n` +
589
- `Each issue must have a clear title, description, priority (1=urgent, 2=high, 3=medium, 4=low), and acceptance criteria.\n\n` +
590
- `Respond ONLY with a JSON array (no other text), e.g.:\n` +
591
- `[\n` +
592
- ` {\n` +
593
- ` "title": "Issue title",\n` +
594
- ` "description": "Clear description of what needs to be done",\n` +
595
- ` "priority": 2,\n` +
596
- ` "acceptanceCriteria": ["Criterion 1", "Criterion 2"]\n` +
597
- ` }\n` +
598
- `]\n\n` +
599
- `Rules:\n` +
600
- `- Generate between 1 and 10 issues\n` +
601
- `- Each issue must be specific and actionable\n` +
602
- `- Priority must be 1, 2, 3, or 4\n` +
603
- `- acceptanceCriteria must be a non-empty array of strings\n` +
604
- `- Respond ONLY with the JSON array, no markdown fences or explanation`;
605
- try {
606
- const raw = await callClaudeSonnet(prompt);
607
- // Parse the array — look for a JSON array in the response
608
- const arrayMatch = raw.match(/\[[\s\S]*\]/);
609
- if (!arrayMatch) {
610
- log.warn({ responsePreview: raw.slice(0, 200) }, "bulk create: no JSON array in Claude response");
611
- return [];
612
- }
613
- let parsed;
614
- try {
615
- parsed = JSON.parse(arrayMatch[0]);
616
- }
617
- catch {
618
- log.warn({ responsePreview: raw.slice(0, 200) }, "bulk create: failed to parse JSON array");
619
- return [];
620
- }
621
- if (!Array.isArray(parsed))
622
- return [];
623
- const specs = [];
624
- for (const item of parsed) {
625
- if (typeof item !== "object" || item === null)
626
- continue;
627
- const title = typeof item.title === "string" ? item.title.trim() : "";
628
- const description = typeof item.description === "string" ? item.description.trim() : "";
629
- const priority = typeof item.priority === "number" && VALID_PRIORITIES.includes(item.priority)
630
- ? item.priority
631
- : DEFAULT_PRIORITY;
632
- const acceptanceCriteria = Array.isArray(item.acceptanceCriteria)
633
- ? item.acceptanceCriteria.filter((c) => typeof c === "string" && c.trim().length > 0)
634
- : [];
635
- if (!title)
636
- continue;
637
- // Cap title/description length to prevent excessively large issues
638
- specs.push({
639
- title: title.slice(0, 200),
640
- description: description.slice(0, 5000),
641
- priority,
642
- acceptanceCriteria: acceptanceCriteria.slice(0, 10),
643
- });
644
- }
645
- return specs.slice(0, 10);
646
- }
647
- catch (err) {
648
- log.warn({ err }, "bulk create: failed to analyze request");
649
- return [];
650
- }
651
- }
652
448
  async function postToResponseUrl(responseUrl, text, responseType = "ephemeral") {
653
449
  await fetch(responseUrl, {
654
450
  method: "POST",
@@ -656,9 +452,43 @@ async function postToResponseUrl(responseUrl, text, responseType = "ephemeral")
656
452
  body: JSON.stringify({ response_type: responseType, text }),
657
453
  });
658
454
  }
659
- async function processMessageAsync(text, channel, botToken, callClaude, deps) {
660
- const cmd = await interpretNaturalLanguage(text, callClaude);
661
- const replyText = await executePmCommand(cmd, deps);
662
- await postSlackMessage(botToken, { channel, text: replyText });
455
+ async function processMessageAsync(text, channel, botToken, callClaude, deps,
456
+ /** Optional message ts so reactions can be swapped after processing completes. */
457
+ ts = null) {
458
+ let success = false;
459
+ try {
460
+ const cmd = await interpretNaturalLanguage(text, callClaude);
461
+ const replyText = await executePmCommand(cmd, deps);
462
+ await postSlackMessage(botToken, { channel, text: replyText });
463
+ success = true;
464
+ }
465
+ finally {
466
+ if (ts) {
467
+ // Best-effort reaction swap. Removing :thinking_face: is non-fatal: if
468
+ // the remove fails (e.g. someone manually removed it), the add still
469
+ // runs — the user just sees one more reaction than expected.
470
+ void removeReaction(botToken, channel, ts, "thinking_face");
471
+ void reactToSlackMessage(botToken, channel, ts, success ? "white_check_mark" : "warning");
472
+ }
473
+ }
474
+ }
475
+ async function removeReaction(botToken, channel, ts, emoji) {
476
+ try {
477
+ const resp = await fetch("https://slack.com/api/reactions.remove", {
478
+ method: "POST",
479
+ headers: {
480
+ "Content-Type": "application/json",
481
+ Authorization: `Bearer ${botToken}`,
482
+ },
483
+ body: JSON.stringify({ channel, timestamp: ts, name: emoji }),
484
+ });
485
+ const data = (await resp.json());
486
+ if (!data?.ok && data?.error !== "no_reaction") {
487
+ log.info({ error: data?.error, channel, ts, emoji }, "Slack reactions.remove returned ok:false");
488
+ }
489
+ }
490
+ catch (err) {
491
+ log.info({ err, channel, ts, emoji }, "Slack reactions.remove failed (non-fatal)");
492
+ }
663
493
  }
664
494
  //# sourceMappingURL=slack-interface.js.map