@zibby/workflow-templates 0.7.0 → 0.9.0

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.
package/index.js CHANGED
@@ -173,7 +173,7 @@ export const TEMPLATES = {
173
173
  description: 'Reusable child workflow — posts a structured Block Kit alert to a Slack channel. Dispatched by other workflows (Sentry triage, autofix, incident) via sub-graph.',
174
174
  path: join(__dirname, 'notify-slack'),
175
175
  defaultSlug: 'alert-slack',
176
- deps: { zod: '^3.23.0' },
176
+ deps: { zod: '^3.23.0 || ^4.0.0', '@zibby/skills': '^0.1.28' },
177
177
  features: [
178
178
  'Single-node, no LLM — deterministic ~500ms post',
179
179
  'Block Kit message with severity-coded color + emoji',
@@ -215,7 +215,7 @@ export const TEMPLATES = {
215
215
  description: 'Reusable child workflow — posts a structured Interactive Card to a Lark / Feishu chat. Dispatched by other workflows via sub-graph.',
216
216
  path: join(__dirname, 'notify-lark'),
217
217
  defaultSlug: 'alert-lark',
218
- deps: { zod: '^3.23.0' },
218
+ deps: { zod: '^3.23.0 || ^4.0.0', '@zibby/skills': '^0.1.28' },
219
219
  features: [
220
220
  'Single-node, no LLM',
221
221
  'Lark Interactive Card with severity template (red/orange/yellow/grey)',
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@zibby/core": "^0.5.1",
13
- "zod": "^3.23.0"
13
+ "@zibby/skills": "^0.1.28",
14
+ "zod": "^3.23.0 || ^4.0.0"
14
15
  },
15
16
  "devDependencies": {
16
17
  "vitest": "^2.1.5"
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@zibby/core": "^0.5.1",
13
- "zod": "^3.23.0"
13
+ "@zibby/skills": "^0.1.28",
14
+ "zod": "^3.23.0 || ^4.0.0"
14
15
  },
15
16
  "devDependencies": {
16
17
  "vitest": "^2.1.5"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zibby/workflow-templates",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Built-in workflow templates for Zibby — browser-test-automation, code-analysis, generate-test-cases, notify-slack, notify-lark, notify-notion, sentry-triage.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -72,7 +72,7 @@
72
72
  "dependencies": {
73
73
  "@anthropic-ai/sdk": "^0.88.0",
74
74
  "@zibby/agent-workflow": "^0.4.2",
75
- "@zibby/skills": "^0.1.25",
75
+ "@zibby/skills": "^0.1.27",
76
76
  "axios": "^1.15.0",
77
77
  "handlebars": "^4.7.9",
78
78
  "zod": "^3.23.0 || ^4.0.0"
@@ -1,28 +1,35 @@
1
1
  /**
2
2
  * sentry-triage — parent workflow. Hourly Sentry issue triage.
3
3
  *
4
- * Pipeline (3 LLM nodes, end-to-end agent-driven):
4
+ * Agent-driven first. Nodes are LLM agents by default — because nobody
5
+ * hand-edits these in practice, they point an AGENT at the prompt and say
6
+ * "make billing always critical" / "page #oncall after 9pm". A deterministic
7
+ * for-loop can't be told that in English; an agent can. We drop to
8
+ * deterministic ONLY where it genuinely makes sense — a pure mechanical step
9
+ * with zero judgment and nothing a customer would ever want to tune.
5
10
  *
6
- * fetch_issues (LLM + SKILLS.SENTRY) list recent unresolved issues
11
+ * fetch_issues (deterministic + SKILLS.SENTRY) pull recent unresolved/
12
+ * unassigned issues + suspect
13
+ * commits. Pure API pull, no
14
+ * judgment, nothing to tune →
15
+ * the one place a for-loop
16
+ * wins (faster, free, no
17
+ * hallucinated queries).
7
18
  * ↓
8
- * classify (LLM, no tools) → label NOISE/LOW/MEDIUM/HIGH/CRITICAL
19
+ * classify (LLM agent) → label NOISECRITICAL. Stays
20
+ * an agent BECAUSE the rubric
21
+ * is customer-tunable, and
22
+ * they tune it by having an
23
+ * agent edit this prompt — not
24
+ * by touching code.
9
25
  * ↓
10
- * dispatch_alerts (LLM + SKILLS.CHAT_NOTIFY) batch + post to Slack OR Lark for
11
- * issues SEVERITY_THRESHOLD
26
+ * dispatch_alerts (LLM agent + SKILLS.CHAT_NOTIFY) one human-voice digest;
27
+ * routing is the user's
28
+ * (DISPATCH_RULES) to own.
12
29
  *
13
- * Why all three nodes are LLM (not deterministic for-loops):
14
- * - At hourly cadence with ≤20 issues/run, LLM cost is $1.50–$32/mo
15
- * depending on model. Trivial relative to Sentry / Slack subscriptions.
16
- * - LLM dispatch can BATCH related issues (5 errors in /checkout/ →
17
- * 1 consolidated message) and DE-DUP near-duplicates. A
18
- * deterministic for-loop can't.
19
- * - outputSchema enforcement guarantees every above-threshold issue
20
- * either gets a "sent" record or an explicit "failed/skipped" —
21
- * no silent drops.
22
- *
23
- * Customize prompts: each node's prompt lives in its own module under
24
- * nodes/. Override per-deploy by editing the file or by passing a
25
- * custom prompt string via inputSchema (planned).
30
+ * Also: LLM dispatch BATCHES related issues into one message and DE-DUPs —
31
+ * a for-loop can't. outputSchema enforcement every above-threshold issue
32
+ * gets a "sent" or explicit "skipped/failed" record; no silent drops.
26
33
  */
27
34
 
28
35
  import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
@@ -48,7 +55,17 @@ export class SentryTriageAgent extends WorkflowAgent {
48
55
  graph.addNode('dispatch_alerts', dispatchNode);
49
56
 
50
57
  graph.setEntryPoint('fetch_issues');
51
- graph.addEdge('fetch_issues', 'classify');
58
+ // Short-circuit when Sentry returned nothing for this window. The
59
+ // empty-list case is the common idle path (steady-state apps don't
60
+ // throw new errors every hour), and running classify + dispatch on
61
+ // an empty input wastes two Claude calls per run — at hourly cadence
62
+ // across many tenants that adds up. Cleaner to route directly to END
63
+ // at the graph level than to short-circuit inside each downstream
64
+ // node's prompt (which still spends a model round-trip).
65
+ graph.addConditionalEdges('fetch_issues', (state) => {
66
+ const issues = state?.fetch_issues?.issues || [];
67
+ return issues.length === 0 ? 'END' : 'classify';
68
+ });
52
69
  graph.addEdge('classify', 'dispatch_alerts');
53
70
  graph.addEdge('dispatch_alerts', 'END');
54
71
 
@@ -1,41 +1,86 @@
1
1
  /**
2
- * dispatch_alerts node — LLM-driven dispatcher.
2
+ * dispatch_alerts node — LLM-driven dispatcher with author routing.
3
3
  *
4
- * The agent sees ALL classified issues + their full data and makes
5
- * judgment calls before calling the chat tool:
6
- * - Bulk related issues into ONE message (5 errors in /checkout/ →
7
- * "⚠️ Checkout spike: 5 errors, top: ...").
8
- * - De-dupe near-duplicates ("seen 3 times, same culprit").
9
- * - Honor SEVERITY_THRESHOLD (skip anything below).
10
- * - Attach mentions only on CRITICAL.
4
+ * The agent reads each classified issue (+ its Sentry suspectCommits
5
+ * author, when available) and decides per-issue where to send the
6
+ * alert. Three layers of decisioning, top-down:
11
7
  *
12
- * Provider routing: chatNotifySkill.resolve() picks the slack OR lark
13
- * MCP server based on which ENV var is set, so the LLM only ever sees
14
- * ONE provider's tools (slack_* or lark_*) it can't accidentally
15
- * call the wrong one.
8
+ * 1. `DISPATCH_RULES` (free-form natural-language override)
9
+ * when set, the agent applies these rules verbatim and treats
10
+ * the structured env vars below as defaults. For advanced
11
+ * routing ("security tag always pages @security regardless of
12
+ * severity", "weekend hours quiet unless CRITICAL", etc.).
16
13
  *
17
- * Reliability: outputSchema enforces a `dispatched` record per
18
- * group + summary counts. A malformed LLM response triggers a retry
19
- * with the schema embedded.
14
+ * 2. Structured env vars (the 90% path):
15
+ * SLACK_CHANNEL / LARK_RECEIVE_ID — pick provider, channel fallback
16
+ * SEVERITY_THRESHOLD — skip anything below
17
+ * ROUTING_PREFER_AUTHOR — if suspectCommit.author email
18
+ * resolves to a user, DM them
19
+ * ROUTING_HIGH_SEVERITY_GROUP — handle/id of a Slack
20
+ * usergroup (@oncall) that gets
21
+ * mentioned on CRITICAL/HIGH
22
+ * SLACK_MENTIONS / LARK_MENTIONS — flat mention list, CRITICAL only
20
23
  *
21
- * ENV tab config:
22
- * SLACK_CHANNEL OR LARK_RECEIVE_ID — required, pick one
24
+ * 3. Built-in defaults (when neither rules nor env set anything):
25
+ * - threshold MEDIUM, no author DM, channel-only post.
26
+ *
27
+ * Author-routing tools the agent has access to (from the slack/lark
28
+ * skills via SKILLS.CHAT_NOTIFY):
29
+ * slack:
30
+ * - slack_lookup_user_by_email email → user id (DM target)
31
+ * - slack_list_usergroups list @oncall etc., with handle + count
32
+ * - slack_get_usergroup_members expand a group → user ids
33
+ * - slack_post_message DM user id OR channel
34
+ * lark:
35
+ * - lark_lookup_user_by_email email → open_id (DM target)
36
+ * - lark_send_message DM open_id OR oc_* group chat
37
+ *
38
+ * Provider selection: chatNotifySkill.resolve() picks slack or lark
39
+ * based on which env var (`SLACK_CHANNEL` or `LARK_RECEIVE_ID`) the
40
+ * project sets. The LLM only sees one provider's tools, so it can't
41
+ * accidentally fan out to the wrong workspace.
42
+ *
43
+ * Reliability: outputSchema enforces a per-dispatch record + summary.
44
+ * A malformed LLM response triggers a retry with the schema embedded.
45
+ *
46
+ * ENV tab config — required:
47
+ * SLACK_CHANNEL OR LARK_RECEIVE_ID — provider selector + channel fallback
48
+ *
49
+ * ENV tab config — optional:
23
50
  * SEVERITY_THRESHOLD — NOISE|LOW|MEDIUM|HIGH|CRITICAL (default MEDIUM)
24
- * SLACK_MENTIONS OR LARK_MENTIONS JSON array, optional, CRITICAL only
51
+ * ROUTING_PREFER_AUTHOR "true" enables suspectCommit author DM
52
+ * ROUTING_HIGH_SEVERITY_GROUP — e.g. "@oncall" or "S012ABC" (Slack usergroup id)
53
+ * SLACK_MENTIONS / LARK_MENTIONS — JSON array of mentions on CRITICAL
54
+ * DISPATCH_RULES — free-form natural-language overrides
25
55
  */
26
56
 
27
57
  import { z, SKILLS } from '@zibby/core';
28
58
  import { SEVERITY_LEVELS } from '../state.js';
29
59
 
60
+ const DispatchedRecordSchema = z.object({
61
+ issueIds: z.array(z.string()).describe('IDs grouped into this message; usually 1, more when batched.'),
62
+ severity: z.enum(SEVERITY_LEVELS),
63
+ status: z.enum(['sent', 'skipped', 'failed']),
64
+ // Every field below a skipped/failed record can't populate is nullish
65
+ // (not optional) on purpose: the LLM emits an explicit `null` rather than
66
+ // omitting the key, and `.optional()` rejects null → ZodError → the whole
67
+ // dispatch node fails even though it did exactly the right thing (skip
68
+ // below-threshold). So recipient (no send → null), messageTs/messageId (no
69
+ // message id), and detail all tolerate null.
70
+ // Who actually received this message. Helps post-hoc auditing of
71
+ // routing decisions ("why did the agent send this to @sarah?").
72
+ recipient: z.object({
73
+ kind: z.enum(['channel', 'user_dm', 'usergroup']).nullish(),
74
+ id: z.string().nullish(),
75
+ label: z.string().nullish(),
76
+ }).nullish(),
77
+ messageTs: z.string().nullish(), // Slack
78
+ messageId: z.string().nullish(), // Lark
79
+ detail: z.string().nullish(),
80
+ });
81
+
30
82
  const DispatchAlertsOutputSchema = z.object({
31
- dispatched: z.array(z.object({
32
- issueIds: z.array(z.string()).describe('IDs grouped into this message; usually 1, more when batched.'),
33
- severity: z.enum(SEVERITY_LEVELS),
34
- status: z.enum(['sent', 'skipped', 'failed']),
35
- messageTs: z.string().optional(), // Slack
36
- messageId: z.string().optional(), // Lark
37
- detail: z.string().optional(),
38
- })),
83
+ dispatched: z.array(DispatchedRecordSchema),
39
84
  summary: z.object({
40
85
  total: z.number().describe('Number of messages POSTED (not issues — batched groups count as 1).'),
41
86
  sent: z.number(),
@@ -44,77 +89,211 @@ const DispatchAlertsOutputSchema = z.object({
44
89
  }),
45
90
  });
46
91
 
92
+ const SEVERITY_ORDER = ['NOISE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
93
+
94
+ // Turn the trigger's sinceMinutes into a phrase a human would actually say,
95
+ // so the digest can open with the time span ("past hour", "last 3 days")
96
+ // instead of leaving the reader to guess how much history this covers.
97
+ function humanWindow(min) {
98
+ const m = Number(min);
99
+ if (!m || m < 1) return 'recently';
100
+ if (m < 90) return `the past ${Math.round(m)} minutes`;
101
+ if (m < 1440) return `the past ${Math.round(m / 60)} hours`;
102
+ const days = Math.round(m / 1440);
103
+ return days === 1 ? 'the past day' : `the past ${days} days`;
104
+ }
105
+
47
106
  const DISPATCH_PROMPT = (state = {}) => {
48
107
  const issues = state?.fetch_issues?.issues || [];
49
108
  const classifications = state?.classify?.classifications || [];
109
+ const windowLabel = humanWindow(state?.sinceMinutes);
110
+ const fetchedAt = state?.fetch_issues?.fetchedAt || '';
111
+
112
+ const threshold = process.env.SEVERITY_THRESHOLD || 'MEDIUM';
113
+ const slackChannel = process.env.SLACK_CHANNEL || '';
114
+ const larkReceiveId = process.env.LARK_RECEIVE_ID || '';
115
+ const preferAuthor = /^(1|true|yes|on)$/i.test(process.env.ROUTING_PREFER_AUTHOR || '');
116
+ const highSevGroup = process.env.ROUTING_HIGH_SEVERITY_GROUP || '';
117
+ const dispatchRules = process.env.DISPATCH_RULES || '';
118
+
119
+ // ── No-op short-circuit ─────────────────────────────────────────
120
+ // The three "nothing to do this run" cases — keep the run green without a
121
+ // model round-trip or forcing channel setup.
122
+ const minSeverityRank = SEVERITY_ORDER.indexOf(threshold);
123
+ const aboveThreshold = minSeverityRank < 0
124
+ ? classifications
125
+ : classifications.filter((c) => SEVERITY_ORDER.indexOf(c.severity) >= minSeverityRank);
50
126
 
51
- const threshold = process.env.SEVERITY_THRESHOLD || 'MEDIUM';
52
- const slackChannel = process.env.SLACK_CHANNEL || '';
53
- const larkReceiveId = process.env.LARK_RECEIVE_ID || '';
127
+ if (issues.length === 0 || classifications.length === 0 || aboveThreshold.length === 0) {
128
+ const reason =
129
+ issues.length === 0 ? 'fetch_issues returned no issues' :
130
+ classifications.length === 0 ? 'classifier emitted no records' :
131
+ `all ${classifications.length} issue(s) below SEVERITY_THRESHOLD=${threshold}`;
132
+ return `No Sentry issues to dispatch this run (${reason}).
133
+
134
+ Return this exact JSON envelope and call no tools:
135
+
136
+ \`\`\`json
137
+ { "dispatched": [], "summary": { "total": 0, "sent": 0, "skipped": 0, "failed": 0 } }
138
+ \`\`\`
139
+ `;
140
+ }
54
141
 
55
- let provider, toolName, recipientLine, mentionsRaw;
142
+ // ── Provider selection ──────────────────────────────────────────
143
+ let provider, postTool, lookupTool, channelId, mentionsRaw;
56
144
  if (slackChannel) {
57
- provider = 'slack';
58
- toolName = 'slack_post_message';
59
- recipientLine = `Post every message to Slack channel: ${JSON.stringify(slackChannel)}\nCall: slack_post_message({ channel: "${slackChannel}", text: "…" })`;
145
+ provider = 'slack';
146
+ postTool = 'slack_post_message';
147
+ lookupTool = 'slack_lookup_user_by_email';
148
+ channelId = slackChannel;
60
149
  mentionsRaw = process.env.SLACK_MENTIONS || '[]';
61
150
  } else if (larkReceiveId) {
62
- provider = 'lark';
63
- toolName = 'lark_send_message';
64
- recipientLine = `Post every message to Lark receive_id: ${JSON.stringify(larkReceiveId)}\nCall: lark_send_message({ receive_id: "${larkReceiveId}", text: "…" })`;
151
+ provider = 'lark';
152
+ postTool = 'lark_send_message';
153
+ lookupTool = 'lark_lookup_user_by_email';
154
+ channelId = larkReceiveId;
65
155
  mentionsRaw = process.env.LARK_MENTIONS || '[]';
66
156
  } else {
67
- throw new Error('sentry-triage: configure SLACK_CHANNEL (for Slack) or LARK_RECEIVE_ID (for Lark) in the ENV tab.');
157
+ throw new Error(
158
+ 'sentry-triage has issues to dispatch but no destination configured. ' +
159
+ 'Go to Project Settings -> ENV and set ONE of:\n' +
160
+ ' - SLACK_CHANNEL=#your-alerts-channel (uses connected Slack integration)\n' +
161
+ ' - LARK_RECEIVE_ID=oc_xxxxxxxx (uses connected Lark integration)\n' +
162
+ 'The integration OAuth token gives the workflow auth, but you still need to tell it WHERE to post.'
163
+ );
68
164
  }
69
165
 
70
166
  let mentions;
71
167
  try { mentions = JSON.parse(mentionsRaw); } catch { mentions = []; }
72
168
  if (!Array.isArray(mentions)) mentions = [];
73
169
 
74
- return `You are the dispatch_alerts node of a Sentry triage workflow. Post chat alerts using the **${toolName}** tool.
170
+ // ── Routing policy ──────────────────────────────────────────────
171
+ // Routing is the USER'S to own. We give the agent only the bare facts it
172
+ // always needs; everything past that is policy:
173
+ // - If DISPATCH_RULES is set, those rules ARE the policy. Hand over the
174
+ // facts + the rules and get out of the way — do NOT also stack the
175
+ // built-in author-DM / usergroup defaults on top. The user chose to
176
+ // drive this themselves; don't fight them.
177
+ // - With no DISPATCH_RULES, fall back to sensible built-in defaults
178
+ // (channel post + opt-in author-DM / escalation from env vars).
179
+ const facts = [`Skip anything classified below ${threshold}.`];
180
+ // Only mention a channel when one is actually configured — never render a
181
+ // "post here" line pointing at an empty value.
182
+ if (channelId) facts.push(`Channel configured: ${JSON.stringify(channelId)} (${provider}).`);
183
+ facts.push(`Post with the \`${postTool}\` tool.`);
184
+
185
+ let policyLines;
186
+ let overrideBlock = '';
187
+ if (dispatchRules) {
188
+ policyLines = facts.concat([
189
+ 'Past the facts above, follow YOUR rules below — who gets paged / DM\'d, where, when, what to suppress. They override anything the built-in defaults would have implied.',
190
+ ]);
191
+ overrideBlock = `\n\n# Your routing rules (authoritative)\n${dispatchRules.trim()}\n`;
192
+ } else {
193
+ policyLines = [...facts];
194
+ if (channelId) policyLines.push('Every alert at minimum goes to the channel above.');
195
+ if (preferAuthor) {
196
+ policyLines.push(
197
+ `Author-DM: when an issue has \`suspectCommits[0].authorEmail\`, FIRST call \`${lookupTool}({ email })\`. ` +
198
+ `If \`ok:true\`, DM that user (their id as the recipient) AND still post the channel for team visibility. ` +
199
+ `If \`ok:false\` or no email, channel-only.`
200
+ );
201
+ }
202
+ if (highSevGroup) {
203
+ policyLines.push(
204
+ provider === 'slack'
205
+ ? `On CRITICAL/HIGH, mention the Slack usergroup ${JSON.stringify(highSevGroup)} in the channel message. ` +
206
+ `Handle (@…): call \`slack_list_usergroups\` once to resolve → id, mention as \`<!subteam^ID>\`. Id (S…): use \`<!subteam^${highSevGroup}>\`.`
207
+ : `On CRITICAL/HIGH, also send to ${JSON.stringify(highSevGroup)} (Lark receive_id).`
208
+ );
209
+ }
210
+ if (mentions.length > 0) policyLines.push(`CRITICAL messages prepend: ${JSON.stringify(mentions.join(' '))}`);
211
+ }
75
212
 
76
- # Recipient
77
- ${recipientLine}
213
+ const policyBlock = policyLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
78
214
 
79
- # Severity threshold
80
- Skip any issue below: ${threshold}
81
- (Severity order, low high: ${SEVERITY_LEVELS.join(' < ')})
215
+ // Slack → a Block Kit card (rich, scannable, clickable View buttons). Lark →
216
+ // the human-voice text digest (no Block Kit there). The JUDGMENT is identical
217
+ // either way — the one-line read, the grouping, what's urgent — only the
218
+ // rendering differs by provider.
219
+ const writeGuide = provider === 'slack'
220
+ ? `# How to report it — like a human on-call, in TWO messages
221
+ Don't cram everything into one card. Report the way a sharp on-call engineer actually would: FIRST a quick human heads-up so people get the situation in ONE glance, THEN the detailed board. So you call \`slack_post_message\` TWICE.
82
222
 
83
- # Mentions
84
- CRITICAL messages onlyprepend: ${JSON.stringify(mentions.join(' '))}
85
- HIGH/MEDIUM/LOW — no mentions.
223
+ ## Message 1 — greeting + the headline (text only, NO blocks)
224
+ \`slack_post_message({ channel, text })\` with just text. Open with a greeting ("👋 Hey team"). Say the ONE thing that matters most the headline STORY, not a count ("billing/auth is broken in 3 spots, almost certainly one deploy"). Page plainly if something needs it ("on-call should grab these now"). End pointing down at the list ("Full breakdown 👇"). 2–4 sentences, sounds like a person typed it — THIS is the "one glance and you get it" message.
225
+ Example:
226
+ "👋 *Hey team — Sentry triage, ${windowLabel}.* Headline: *billing/auth is broken in 3 places* (formatSubscription, webhook timeout, Unauthorized) — almost certainly one bad deploy, on-call should grab these now. Plus a cluster of undefined-ref fatals (module/import wiring) and the usual synthetic test noise. Full breakdown 👇"
86
227
 
87
- # Your judgment
88
- - Batch issues with the same culprit / metadata.filename into ONE message.
89
- - De-dupe near-duplicates (e.g. same error text in different paths). Mention "seen N times".
90
- - Keep each message short. Lead with severity in *[BRACKETS]*. Include the Sentry permalink so the on-call can click through.
228
+ ## Message 2 — the Block Kit board (blocks)
229
+ Then \`slack_post_message({ channel, text, blocks })\` — the scannable card. \`text\` = one-line fallback. \`blocks\`, real Block Kit objects only:
230
+ 1. \`header\` title with the window:
231
+ { "type": "header", "text": { "type": "plain_text", "text": "🚨 Sentry Triage ${windowLabel}", "emoji": true } }
232
+ 2. \`context\` — ONE-line read of the window (counts + the shape; your call):
233
+ { "type": "context", "elements": [{ "type": "mrkdwn", "text": "*${aboveThreshold.length}* at *${threshold}+* of ${issues.length} · <your one-line read of what's going on>" }] }
234
+ 3. Per group — group by ROOT CAUSE or severity (your call; the header says what connects them). A divider, a section header, then per issue a section (with a View button) FOLLOWED BY a one-line context note:
235
+ { "type": "divider" }
236
+ { "type": "section", "text": { "type": "mrkdwn", "text": "🔴 *CRITICAL — billing/auth, page on-call*" } }
237
+ { "type": "section", "text": { "type": "mrkdwn", "text": "*<title>* — <where> · <the one metric that matters>" }, "accessory": { "type": "button", "text": { "type": "plain_text", "text": "View →", "emoji": true }, "url": "<permalink>", "action_id": "v_<issueId>" } }
238
+ { "type": "context", "elements": [{ "type": "mrkdwn", "text": "↳ <one short useful detail>" }] }
239
+ The context line is grey small text under the issue — ~6-12 words of a CONCRETE fact pulled straight from the issue's data, NEVER a vague guess. Use what's actually there: the error value (\`metadata.value\`, e.g. "Cannot read 'plan' of undefined"), the culprit fn/file, a real suspect commit ("a1b2c3 by sarah@"), or first/last-seen spread ("first seen 3d ago, 1.6k/h now"). BANNED — filler with no information: "could exacerbate latency", "potential breach attempt", "needs attention", "affects capacity". If you don't have a concrete fact for an issue, OMIT its context line entirely. Never speculate to fill space.
240
+ 4. final \`context\` — what needs a human now vs. just FYI.
91
241
 
92
- # Message format (template, adapt as needed)
93
- \`\`\`
94
- *[CRITICAL]* TypeError: Cannot read 'id' of undefined
95
- 12 users hit /checkoutlikely regression on r1234.
96
- 📍 handleCheckout(checkout.ts) · 47 events
97
- https://sentry.io/.../1234/
98
- \`\`\`
242
+ Rules:
243
+ - TWO slack_post_message calls: the text heads-up FIRST, then the blocks card. Both go to the same channel.
244
+ - header text is plain_text; section & context text is mrkdwn (*bold*, \`code\`, <url|label>).
245
+ - One tight line per issue in the section text; the button carries the link don't also inline it.
246
+ - Group dots: 🔴 CRITICAL · 🟠 HIGH · 🟡 MEDIUM. Mention a suspect commit only if there genuinely is one.
247
+ - Below-threshold (skipped) issues do NOT appear in the blocks at all.
248
+ - Real Block Kit types only (header / section / divider / context + button accessory) — don't invent types.`
249
+ : `# How to write it — talk like a human, not a report generator
250
+ You're a teammate dropping a note in the channel, not a dashboard. Open with a real sentence about ${windowLabel} (time span baked in). Group issues that are the same story (same file/area/deploy) and SAY why they're connected. Per issue: what broke, the one number that matters, and the link. End straight: what needs a human now vs. FYI. No "*[SEVERITY]*" form blocks, no "no suspect commits" filler.
99
251
 
100
- # Output (outputSchema-enforced)
252
+ Example tone:
253
+ Over ${windowLabel} it's been mostly quiet, but billing's having a bad time — three errors on the checkout/subscription path, almost certainly the same deploy: \`formatSubscription is not a function\` (BillingPage, 1 user), \`POST /billing/webhook\` timing out (6×), \`countWorkflowExecutionsInPeriod\` 15× in usage-limiter. <links> Whoever shipped the billing refactor should roll back. Rest is synthetic test traffic — ignoring it.`;
101
254
 
102
- Return ONE record per ${toolName} call you actually made (or skipped/failed).
103
- \`issueIds\` is an array for batched messages it carries every issue in the group.
104
- \`severity\` is the highest severity in the group.
255
+ // ── Prompt body ─────────────────────────────────────────────────
256
+ return `You are the dispatch_alerts node of a Sentry triage workflow. Post chat alerts using the **${postTool}** tool (and the lookup helpers below for author routing).
257
+
258
+ # Routing
259
+ ${policyBlock}${overrideBlock}
260
+
261
+ # Severity scale
262
+ ${SEVERITY_LEVELS.join(' < ')}
263
+
264
+ # Tools you should use
265
+ - \`${postTool}\` — post the message (channel id OR user/DM id for the recipient field).
266
+ - \`${lookupTool}\` — resolve an email to a user id. **Important**: this can return \`{ ok: false }\` — handle that by falling back to channel-only, don't retry with variations of the email.
267
+ ${provider === 'slack' ? '- `slack_list_usergroups` / `slack_get_usergroup_members` — expand @group → user ids.' : ''}
268
+
269
+ # Context for THIS run — weave it in, don't make the reader guess
270
+ - Time span: every issue below is unresolved + unassigned from **${windowLabel}**${fetchedAt ? ` (pulled ${fetchedAt})` : ''}. OPEN with the span so people know what they're looking at — "Past hour was quiet…", "Over the last 30 days…". Never leave it out; a reader who doesn't know the window can't judge urgency.
271
+ - Volume: ${aboveThreshold.length} issue(s) at or above ${threshold}, out of ${issues.length} fetched. Mention the count only if it helps the read.
272
+
273
+ ${writeGuide}
274
+
275
+ A standalone CRITICAL that should page someone can get its OWN message. Routing still applies: digest → channel; if author-DM is on and an issue has a known author, also DM that person a short note about just theirs; mention the escalation group on CRITICAL/HIGH.
276
+
277
+ # Output (outputSchema-enforced)
278
+ Return ONE record per dispatch call you actually made (or skipped/failed). \`issueIds\` is an array — batched messages carry every issue in the group. \`recipient\` records who got the message (channel id, user id, or usergroup id) so the audit trail shows the routing decision.
105
279
 
106
280
  \`\`\`json
107
281
  {
108
282
  "dispatched": [
109
- { "issueIds": ["1", "5", "7"], "severity": "CRITICAL", "status": "sent"${provider === 'slack' ? ', "messageTs": "1716109330.555"' : ', "messageId": "om_xxxxx"'} }
283
+ {
284
+ "issueIds": ["1", "5"],
285
+ "severity": "CRITICAL",
286
+ "status": "sent",
287
+ "recipient": { "kind": "user_dm", "id": "U012ABC", "label": "sarah@acme.com" }${provider === 'slack' ? ',\n "messageTs": "1716109330.555"' : ',\n "messageId": "om_xxxxx"'}
288
+ }
110
289
  ],
111
290
  "summary": { "total": 1, "sent": 1, "skipped": 0, "failed": 0 }
112
291
  }
113
292
  \`\`\`
114
293
 
115
- # Issues + classifications
294
+ # Issues + classifications + suspect commits
116
295
 
117
- Each entry below has the Sentry issue plus the classifier's verdict + reasoning. Use both.
296
+ Each entry is a Sentry issue with the classify agent's \`classification\` (severity + reasoning) and any suspect commits Sentry's GitHub integration could blame. **An empty \`suspectCommits\` array means the team hasn't set up Sentry's GitHub integration OR the file wasn't touched in the last 14 days** — just don't mention a commit in that case.
118
297
 
119
298
  \`\`\`json
120
299
  ${JSON.stringify(
@@ -128,10 +307,11 @@ ${JSON.stringify(
128
307
  \`\`\`
129
308
 
130
309
  # Rules
131
- - Skip below-threshold issues silently (just include them in dispatched with status="skipped"; no chat call).
132
- - DON'T invent severities or issue IDs. Use what's given.
133
- - DON'T post more messages than necessary. If 5 issues are clearly one bug, post 1 message.
134
- - DO post if in doubt under-paging is worse than over-paging for triage.
310
+ - The digest is usually ONE channel message → ONE \`sent\` record whose \`issueIds\` lists every issue you mentioned in it. An extra author-DM or a standalone-CRITICAL message each get their own record too.
311
+ - Skipped (below-threshold) issues: roll them into a single \`skipped\` record (issueIds = all of them) — no chat call, no per-issue noise so the run record stays complete without bloating it.
312
+ - DON'T invent severities, issue IDs, or email addresses. Only use what's in the data block above.
313
+ - DON'T pad the digest. If the hour is quiet, a two-line message is the right answer — don't manufacture structure.
314
+ - DO post if in doubt — under-paging is worse than over-paging.
135
315
  `;
136
316
  };
137
317
 
@@ -26,7 +26,21 @@
26
26
 
27
27
  import { z } from 'zod';
28
28
  import { SKILLS } from '@zibby/core';
29
- import { sentryListIssues } from '@zibby/skills/sentry';
29
+ import { sentryListIssues, sentryGetIssue } from '@zibby/skills/sentry';
30
+
31
+ // Per-commit shape Sentry returns under `suspectCommits[]`. We only
32
+ // surface what the dispatch agent actually uses for routing — author
33
+ // email (→ slack_lookup_user_by_email) + a short SHA + the commit
34
+ // message so the agent can sanity-check "is this commit plausibly the
35
+ // cause?". Everything else (date, repository url, etc.) is dropped to
36
+ // keep the per-issue payload small.
37
+ const SuspectCommitShape = z.object({
38
+ id: z.string().optional(),
39
+ shortId: z.string().optional(),
40
+ message: z.string().optional(),
41
+ authorEmail: z.string().optional(),
42
+ authorName: z.string().optional(),
43
+ });
30
44
 
31
45
  const IssueShape = z.object({
32
46
  id: z.string(),
@@ -45,6 +59,11 @@ const IssueShape = z.object({
45
59
  value: z.string().optional(),
46
60
  filename: z.string().optional(),
47
61
  }).optional(),
62
+ // Populated by the per-issue sentryGetIssue() fetch below. Empty
63
+ // array when Sentry's GitHub integration can't blame the issue's
64
+ // stack frames to any recent commits (no integration / no code
65
+ // mapping / file untouched in 14 days).
66
+ suspectCommits: z.array(SuspectCommitShape).optional(),
48
67
  });
49
68
 
50
69
  const FetchIssuesOutputSchema = z.object({
@@ -65,10 +84,24 @@ export const fetchIssuesNode = {
65
84
  : context;
66
85
 
67
86
  const sinceMinutes = Number(state?.sinceMinutes) || 60;
87
+ const query = `is:unresolved is:unassigned firstSeen:-${sinceMinutes}m`;
88
+
89
+ // Surface intent BEFORE the call. Deterministic nodes don't go
90
+ // through an LLM that would print its reasoning, so the activity
91
+ // panel would otherwise just show "started" then a result count —
92
+ // operators have no way to tell what query ran or whether the empty
93
+ // result was "Sentry returned nothing" vs "we asked the wrong
94
+ // question". Two-line preamble fixes that without log-spam.
95
+ console.log(`Sentry query: ${query}`);
96
+ console.log(`Sort: new (firstSeen desc) · Limit: 100`);
68
97
 
69
98
  const issues = await sentryListIssues({
70
- query: `is:unresolved is:unassigned firstSeen:-${sinceMinutes}m`,
71
- sort: 'created',
99
+ query,
100
+ // Sentry's /projects/{org}/{project}/issues/ endpoint only accepts
101
+ // these sort keys: date | new | priority | freq | user. `new` sorts
102
+ // by firstSeen desc which matches our triage intent (newest issues
103
+ // surface first). `created` was wrong and made Sentry return 400.
104
+ sort: 'new',
72
105
  // 100 issues is the practical ceiling for a triage notification.
73
106
  // Beyond that, classify+dispatch lose signal — a "deluge" digest
74
107
  // tells the user nothing actionable. If a customer regularly
@@ -77,8 +110,55 @@ export const fetchIssuesNode = {
77
110
  limit: 100,
78
111
  });
79
112
 
113
+ // Always log the count + a head sample. On 0 issues this prints
114
+ // "Fetched 0 issues" which is the actionable signal — operator
115
+ // knows the query worked but there's nothing new to triage.
116
+ console.log(`Fetched ${issues.length} issue${issues.length === 1 ? '' : 's'} from Sentry`);
117
+ if (issues.length > 0) {
118
+ const preview = issues.slice(0, 5).map((i) => ` - ${i.shortId || i.id} [${i.level || '?'}] ${i.title || '(no title)'}`);
119
+ console.log(preview.join('\n'));
120
+ if (issues.length > 5) console.log(` ... (${issues.length - 5} more)`);
121
+ }
122
+
123
+ // Enrich each issue with `suspectCommits` (author email + SHA +
124
+ // commit message). The /issues/ list endpoint does NOT include
125
+ // suspectCommits — only /issues/<id>/ does. So we do N parallel
126
+ // GETs to hydrate. Practical concerns:
127
+ //
128
+ // - N is capped at 100 (the list limit above). One parallel
129
+ // batch finishes in ~1-2s against sentry.io.
130
+ // - Per-issue failures are swallowed (logged + empty array)
131
+ // so one 404 doesn't break the entire run. The classifier
132
+ // downstream doesn't NEED suspectCommits to function — it's
133
+ // a routing hint, not a classification input.
134
+ // - When the customer hasn't installed Sentry's GitHub
135
+ // integration OR hasn't configured Code Mappings, every
136
+ // issue will come back with suspectCommits=[]. That's the
137
+ // designed-for steady state; dispatch agent falls back to
138
+ // channel-only routing.
139
+ const enriched = await Promise.all(
140
+ issues.map(async (issue) => {
141
+ try {
142
+ const detail = await sentryGetIssue(issue.id);
143
+ const sc = (detail?.suspectCommits || []).map((c) => ({
144
+ id: c.id,
145
+ shortId: c.shortId || (c.id ? String(c.id).slice(0, 7) : undefined),
146
+ message: c.message,
147
+ authorEmail: c.author?.email,
148
+ authorName: c.author?.name,
149
+ }));
150
+ return { ...issue, suspectCommits: sc };
151
+ } catch (err) {
152
+ console.warn(` · suspectCommits fetch failed for ${issue.shortId || issue.id}: ${err.message}`);
153
+ return { ...issue, suspectCommits: [] };
154
+ }
155
+ })
156
+ );
157
+ const withAuthor = enriched.filter((i) => (i.suspectCommits || []).some((c) => c.authorEmail)).length;
158
+ console.log(`Hydrated suspectCommits — ${withAuthor}/${enriched.length} issue(s) have an identifiable author.`);
159
+
80
160
  return {
81
- issues,
161
+ issues: enriched,
82
162
  fetchedAt: new Date().toISOString(),
83
163
  };
84
164
  },
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@zibby/core": "^0.5.1",
13
- "@zibby/skills": "^0.1.25",
13
+ "@zibby/skills": "^0.1.26",
14
14
  "zod": "^3.23.0"
15
15
  },
16
16
  "devDependencies": {
@@ -1 +0,0 @@
1
- {"version":"4.1.5","results":[[":__tests__/preflight-early-exit.test.mjs",{"duration":6.5747499999999945,"failed":false}]]}
@@ -1 +0,0 @@
1
- {"version":"4.1.5","results":[[":nodes/__tests__/middleware.integration.test.js",{"duration":0,"failed":true}],[":nodes/__tests__/finalizeNode.test.js",{"duration":8.396791000000007,"failed":false}]]}