pm-slack 2026.6.2 → 2026.6.4

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/dist/index.js CHANGED
@@ -13,9 +13,24 @@
13
13
  * PM_SLACK_WEBHOOK (required) Slack incoming webhook URL
14
14
  * PM_SLACK_CHANNEL (optional) Override channel, e.g. #pm-alerts
15
15
  * PM_SLACK_MIN_PRIORITY (optional) Minimum priority to notify (1=critical … 4=low), default 1 (critical only; set 4 for all)
16
- * PM_SLACK_EVENTS (optional) Comma-separated subset of hook events: create,close,block (default: all)
16
+ * PM_SLACK_EVENTS (optional) Comma-separated subset of hook events:
17
+ * create,close,block,cancel,open,start,unblock,reopen (default: all).
18
+ * Status-name aliases (e.g. "canceled","in_progress") are accepted.
19
+ * PM_SLACK_FORMAT (optional) Default notification format: "blockkit" (default) or "text"
20
+ * PM_SLACK_ASSIGNEE_MAP (optional) Comma list of "name=slackId" pairs mapping a pm item's
21
+ * assignee to a Slack @mention, e.g. "alice=U123,bob=U456".
22
+ * PM_SLACK_MENTION_ASSIGNEE (optional) Set to 0/false to disable assignee @mentions even when a map is set
23
+ * (mentions auto-enable in the hook whenever PM_SLACK_ASSIGNEE_MAP is non-empty).
24
+ * PM_SLACK_ROUTES (optional) JSON array of routing rules to send specific
25
+ * events/types/statuses to different webhooks/channels.
26
+ * Each rule: { "match": "<selector>", "webhook"?: "...", "channel"?: "..." }
27
+ * Selector forms: an event ("create"|"close"|"block"),
28
+ * "type:<itemType>" (case-insensitive), or "status:<status>".
29
+ * First matching rule wins; unset fields fall back to the defaults.
17
30
  */
18
31
  import https from "node:https";
32
+ import fs from "node:fs";
33
+ import path from "node:path";
19
34
  const defineExtension = ((extension) => extension);
20
35
  // ---------------------------------------------------------------------------
21
36
  // EXIT_CODE / CommandError (re-implemented locally)
@@ -77,35 +92,181 @@ function readStrOption(options, key) {
77
92
  // ---------------------------------------------------------------------------
78
93
  // Event parsing
79
94
  // ---------------------------------------------------------------------------
80
- const ALL_EVENTS = ["create", "close", "block"];
95
+ const ALL_EVENTS = [
96
+ "create",
97
+ "close",
98
+ "block",
99
+ "cancel",
100
+ "open",
101
+ "start",
102
+ "unblock",
103
+ "reopen",
104
+ ];
105
+ /**
106
+ * Friendly aliases accepted in PM_SLACK_EVENTS / `--on` so callers can write the
107
+ * status name they think in (e.g. "canceled"/"cancelled" → cancel,
108
+ * "in_progress" → start). Aliases normalize to a canonical EventKind.
109
+ */
110
+ const EVENT_ALIASES = {
111
+ created: "create",
112
+ closed: "close",
113
+ done: "close",
114
+ complete: "close",
115
+ completed: "close",
116
+ resolved: "close",
117
+ blocked: "block",
118
+ canceled: "cancel",
119
+ cancelled: "cancel",
120
+ opened: "open",
121
+ in_progress: "start",
122
+ "in-progress": "start",
123
+ started: "start",
124
+ unblocked: "unblock",
125
+ reopened: "reopen",
126
+ };
127
+ function normalizeEvent(token) {
128
+ const t = token.trim().toLowerCase();
129
+ if (ALL_EVENTS.includes(t))
130
+ return t;
131
+ return EVENT_ALIASES[t] ?? null;
132
+ }
81
133
  function parseEvents(spec) {
82
134
  if (!spec)
83
135
  return new Set(ALL_EVENTS);
84
136
  const parsed = spec
85
137
  .split(",")
86
- .map((e) => e.trim().toLowerCase())
87
- .filter((e) => ALL_EVENTS.includes(e));
138
+ .map((e) => normalizeEvent(e))
139
+ .filter((e) => e !== null);
88
140
  return parsed.length > 0 ? new Set(parsed) : new Set(ALL_EVENTS);
89
141
  }
142
+ // ---------------------------------------------------------------------------
143
+ // Format parsing
144
+ // ---------------------------------------------------------------------------
145
+ const ALL_FORMATS = ["blockkit", "text"];
146
+ /**
147
+ * Normalize a format spec to a known MessageFormat. Accepts a few friendly
148
+ * aliases ("block"/"blocks" → blockkit, "plain"/"txt" → text). Falls back to
149
+ * the supplied default when the spec is empty or unrecognized.
150
+ */
151
+ function parseFormat(spec, fallback = "blockkit") {
152
+ if (!spec)
153
+ return fallback;
154
+ const v = spec.trim().toLowerCase();
155
+ if (v === "blockkit" || v === "block" || v === "blocks" || v === "rich")
156
+ return "blockkit";
157
+ if (v === "text" || v === "plain" || v === "txt")
158
+ return "text";
159
+ return fallback;
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Routing
163
+ //
164
+ // PM_SLACK_ROUTES is an optional JSON array of rules that send specific events,
165
+ // item types, or statuses to a different webhook/channel. Routing is purely
166
+ // additive: with no rules configured, behavior is identical to before.
167
+ // ---------------------------------------------------------------------------
168
+ function parseRoutes(spec) {
169
+ if (!spec || !spec.trim())
170
+ return [];
171
+ let raw;
172
+ try {
173
+ raw = JSON.parse(spec);
174
+ }
175
+ catch {
176
+ console.error("[pm-slack] PM_SLACK_ROUTES is not valid JSON — routing disabled");
177
+ return [];
178
+ }
179
+ if (!Array.isArray(raw)) {
180
+ console.error("[pm-slack] PM_SLACK_ROUTES must be a JSON array — routing disabled");
181
+ return [];
182
+ }
183
+ const rules = [];
184
+ for (const entry of raw) {
185
+ if (!entry || typeof entry !== "object")
186
+ continue;
187
+ const e = entry;
188
+ const match = typeof e.match === "string" ? e.match.trim() : "";
189
+ if (!match)
190
+ continue;
191
+ const webhook = typeof e.webhook === "string" && e.webhook.trim() ? e.webhook.trim() : undefined;
192
+ const channel = typeof e.channel === "string" && e.channel.trim() ? e.channel.trim() : undefined;
193
+ if (!webhook && !channel)
194
+ continue; // a rule with no override is meaningless
195
+ rules.push({ match, webhook, channel });
196
+ }
197
+ return rules;
198
+ }
199
+ /**
200
+ * Does a single route rule match this event/item? Selector forms:
201
+ * - bare event name: "create" | "close" | "block"
202
+ * - "type:<itemType>" (case-insensitive on item.type)
203
+ * - "status:<status>" (case-insensitive on item.status)
204
+ * - "*" / "all" (matches everything — useful as a catch-all)
205
+ */
206
+ function ruleMatches(rule, event, item) {
207
+ const sel = rule.match.toLowerCase();
208
+ if (sel === "*" || sel === "all")
209
+ return true;
210
+ if (sel.startsWith("type:")) {
211
+ return (item.type ?? "").toLowerCase() === sel.slice("type:".length).trim();
212
+ }
213
+ if (sel.startsWith("status:")) {
214
+ return (item.status ?? "").toLowerCase() === sel.slice("status:".length).trim();
215
+ }
216
+ return sel === event;
217
+ }
218
+ /**
219
+ * Resolve the destination (webhook + channel) for an event/item given the
220
+ * configured routes and the default webhook/channel. First matching rule wins;
221
+ * any field a rule omits falls back to the default. Returns null only when no
222
+ * webhook can be resolved at all.
223
+ */
224
+ function selectRoute(event, item, routes, defaultWebhook, defaultChannel) {
225
+ for (const rule of routes) {
226
+ if (ruleMatches(rule, event, item)) {
227
+ const webhookUrl = rule.webhook ?? defaultWebhook;
228
+ if (!webhookUrl)
229
+ return null;
230
+ return { webhookUrl, channel: rule.channel ?? defaultChannel };
231
+ }
232
+ }
233
+ if (!defaultWebhook)
234
+ return null;
235
+ return { webhookUrl: defaultWebhook, channel: defaultChannel };
236
+ }
90
237
  function loadConfig() {
91
238
  const webhookUrl = process.env.PM_SLACK_WEBHOOK ?? "";
92
- if (!webhookUrl) {
239
+ const routes = parseRoutes(process.env.PM_SLACK_ROUTES);
240
+ // A default webhook is not strictly required if routing rules carry their own
241
+ // webhook(s); only bail out when there is no webhook source at all.
242
+ const routesHaveWebhook = routes.some((r) => r.webhook);
243
+ if (!webhookUrl && !routesHaveWebhook) {
93
244
  console.error("[pm-slack] PM_SLACK_WEBHOOK not set — notifications disabled");
94
245
  return null;
95
246
  }
96
- // Validate URL
97
- try {
98
- new URL(webhookUrl);
99
- }
100
- catch {
101
- console.error("[pm-slack] PM_SLACK_WEBHOOK is not a valid URL — notifications disabled");
102
- return null;
247
+ // Validate the default URL when present (route webhooks are validated lazily).
248
+ if (webhookUrl) {
249
+ try {
250
+ new URL(webhookUrl);
251
+ }
252
+ catch {
253
+ console.error("[pm-slack] PM_SLACK_WEBHOOK is not a valid URL — notifications disabled");
254
+ return null;
255
+ }
103
256
  }
104
257
  const channel = process.env.PM_SLACK_CHANNEL?.trim() || undefined;
105
258
  const rawPriority = parseInt(process.env.PM_SLACK_MIN_PRIORITY ?? "1", 10);
106
259
  const minPriority = rawPriority >= 1 && rawPriority <= 4 ? rawPriority : 1;
107
260
  const events = parseEvents(process.env.PM_SLACK_EVENTS);
108
- return { webhookUrl, channel, minPriority, events };
261
+ const format = parseFormat(process.env.PM_SLACK_FORMAT);
262
+ const assigneeMap = parseAssigneeMap(process.env.PM_SLACK_ASSIGNEE_MAP);
263
+ // Mentions are auto-enabled in the hook whenever a non-empty map is present;
264
+ // PM_SLACK_MENTION_ASSIGNEE=0/false can force them off even with a map.
265
+ const mentionEnv = (process.env.PM_SLACK_MENTION_ASSIGNEE ?? "").trim().toLowerCase();
266
+ const mentionAssignee = mentionEnv === "0" || mentionEnv === "false" || mentionEnv === "no" || mentionEnv === "off"
267
+ ? false
268
+ : assigneeMap.size > 0;
269
+ return { webhookUrl, channel, minPriority, events, format, routes, assigneeMap, mentionAssignee };
109
270
  }
110
271
  // ---------------------------------------------------------------------------
111
272
  // Priority helpers
@@ -126,6 +287,89 @@ function meetsMinPriority(item, minPriority) {
126
287
  return item.priority <= minPriority;
127
288
  }
128
289
  // ---------------------------------------------------------------------------
290
+ // Assignee → Slack @mention mapping
291
+ //
292
+ // PM_SLACK_ASSIGNEE_MAP maps a pm item's assignee to a Slack user/group id so
293
+ // notifications can @-mention the responsible person. Format is a comma-list of
294
+ // `name=id` pairs, e.g. PM_SLACK_ASSIGNEE_MAP="alice=U123,bob=U456". Slack
295
+ // renders <@U123> as a user mention and <!subteam^S123> for groups; we accept a
296
+ // raw id (wrapped as <@id>) or a pre-wrapped token (kept verbatim). Mapping is
297
+ // opt-in and additive — when unset, output is byte-identical to before.
298
+ // ---------------------------------------------------------------------------
299
+ /** Parse PM_SLACK_ASSIGNEE_MAP into a case-insensitive name→id lookup. */
300
+ function parseAssigneeMap(spec) {
301
+ const map = new Map();
302
+ if (!spec || !spec.trim())
303
+ return map;
304
+ for (const pair of spec.split(",")) {
305
+ const eq = pair.indexOf("=");
306
+ if (eq <= 0)
307
+ continue;
308
+ const name = pair.slice(0, eq).trim().toLowerCase();
309
+ const id = pair.slice(eq + 1).trim();
310
+ if (name && id)
311
+ map.set(name, id);
312
+ }
313
+ return map;
314
+ }
315
+ /**
316
+ * Resolve an item's assignee to a Slack mention token using the map. Returns a
317
+ * ready-to-embed mention string (e.g. `<@U123>`) or undefined when there's no
318
+ * assignee or no mapping. A raw id is wrapped as `<@id>`; a token already
319
+ * containing `<` is passed through unchanged (supports `<!subteam^S123>` etc).
320
+ */
321
+ function resolveAssigneeMention(item, map) {
322
+ const assignee = item.assignee?.trim();
323
+ if (!assignee || map.size === 0)
324
+ return undefined;
325
+ const id = map.get(assignee.toLowerCase());
326
+ if (!id)
327
+ return undefined;
328
+ return id.startsWith("<") ? id : `<@${id}>`;
329
+ }
330
+ // ---------------------------------------------------------------------------
331
+ // Item URL extraction (for Block Kit action buttons)
332
+ //
333
+ // pm has no canonical URL field, so read a defensive set of common field names
334
+ // off the item; the first valid http(s) URL wins. Used to render an "Open item"
335
+ // / "View on GitHub" action button in the Block Kit notification (Feature 3).
336
+ // ---------------------------------------------------------------------------
337
+ function isHttpUrl(value) {
338
+ if (typeof value !== "string")
339
+ return false;
340
+ const v = value.trim();
341
+ if (!v)
342
+ return false;
343
+ try {
344
+ const u = new URL(v);
345
+ return u.protocol === "http:" || u.protocol === "https:";
346
+ }
347
+ catch {
348
+ return false;
349
+ }
350
+ }
351
+ /** Resolve a primary URL for the item, plus whether it points at GitHub. */
352
+ function resolveItemUrl(item, override) {
353
+ const candidates = [
354
+ override,
355
+ item.github_url,
356
+ item.githubUrl,
357
+ item.html_url,
358
+ item.htmlUrl,
359
+ item.url,
360
+ item.source_url,
361
+ item.sourceUrl,
362
+ item.link,
363
+ ];
364
+ for (const c of candidates) {
365
+ if (isHttpUrl(c)) {
366
+ const url = c.trim();
367
+ return { url, isGithub: /(^|\.)github\.com$/i.test(new URL(url).hostname) };
368
+ }
369
+ }
370
+ return undefined;
371
+ }
372
+ // ---------------------------------------------------------------------------
129
373
  // Plain-text message formatting (fallback)
130
374
  // ---------------------------------------------------------------------------
131
375
  function itemTypeLabel(item) {
@@ -135,26 +379,47 @@ const EVENT_META = {
135
379
  create: { verb: "created", emoji: "🆕" },
136
380
  close: { verb: "closed", emoji: "✅" },
137
381
  block: { verb: "is blocked", emoji: "🚫" },
382
+ cancel: { verb: "canceled", emoji: "🛑" },
383
+ open: { verb: "opened", emoji: "📂" },
384
+ start: { verb: "started", emoji: "🔄" },
385
+ unblock: { verb: "unblocked", emoji: "🔓" },
386
+ reopen: { verb: "reopened", emoji: "♻️" },
138
387
  };
139
388
  function eventReason(item, event) {
140
389
  if (event === "close") {
141
390
  return item.close_reason?.trim() || item.closedReason?.trim() || undefined;
142
391
  }
392
+ if (event === "cancel") {
393
+ // pm persists the cancel reason in close_reason; honor a dedicated
394
+ // cancel_reason first if a future pm version introduces one.
395
+ return (item.cancel_reason?.trim() ||
396
+ item.cancelReason?.trim() ||
397
+ item.close_reason?.trim() ||
398
+ item.closedReason?.trim() ||
399
+ undefined);
400
+ }
143
401
  if (event === "block") {
144
402
  return item.blocked_reason?.trim() || item.blockedReason?.trim() || undefined;
145
403
  }
146
404
  return undefined;
147
405
  }
148
- function buildTextMessage(item, event, channel) {
406
+ function buildTextMessage(item, event, channel, mention) {
149
407
  const type = itemTypeLabel(item);
150
408
  const meta = EVENT_META[event];
151
409
  let msg = `*[${type}]* ${item.title} ${meta.verb} ${meta.emoji}`;
152
410
  if (event === "create") {
153
411
  msg += `\nPriority: ${priorityLabel(item.priority)} • Type: ${type} • By: ${item.author ?? "unknown"}`;
154
412
  }
155
- else {
413
+ else if (event === "close" || event === "cancel" || event === "block") {
414
+ // Terminal/blocked transitions carry a reason field.
156
415
  msg += `\nReason: ${eventReason(item, event) ?? "no reason given"}`;
157
416
  }
417
+ else {
418
+ // Status transitions without a reason (open/start/unblock/reopen).
419
+ msg += `\nStatus: ${item.status ?? meta.verb}${item.assignee ? ` • Assignee: ${item.assignee}` : ""}`;
420
+ }
421
+ if (mention)
422
+ msg += `\nAssignee: ${mention}`;
158
423
  if (channel)
159
424
  msg += `\n_Channel: ${channel}_`;
160
425
  return msg;
@@ -178,6 +443,12 @@ function buildItemBlockKit(item, event, opts = {}) {
178
443
  fields.push({ type: "mrkdwn", text: `*Status:*\n${item.status}` });
179
444
  if (item.author)
180
445
  fields.push({ type: "mrkdwn", text: `*By:*\n${item.author}` });
446
+ // Assignee mention (Feature 2): show the @mention when mapped, else the raw
447
+ // assignee name so the field is still useful without a configured map.
448
+ if (opts.mention)
449
+ fields.push({ type: "mrkdwn", text: `*Assignee:*\n${opts.mention}` });
450
+ else if (item.assignee)
451
+ fields.push({ type: "mrkdwn", text: `*Assignee:*\n${item.assignee}` });
181
452
  blocks.push({ type: "section", fields });
182
453
  const reason = eventReason(item, event);
183
454
  if (reason) {
@@ -192,6 +463,24 @@ function buildItemBlockKit(item, event, opts = {}) {
192
463
  text: { type: "mrkdwn", text: opts.note },
193
464
  });
194
465
  }
466
+ // Action buttons (Feature 3): a link button to the item / GitHub URL when one
467
+ // is present on the item. Block Kit "actions" with a url-bearing button needs
468
+ // no interactivity backend — it opens the URL directly. Plain-text path is
469
+ // unaffected (this only runs for the blockkit format).
470
+ if (opts.link) {
471
+ const label = opts.link.isGithub ? "View on GitHub" : "Open item";
472
+ blocks.push({
473
+ type: "actions",
474
+ elements: [
475
+ {
476
+ type: "button",
477
+ text: { type: "plain_text", text: label, emoji: true },
478
+ url: opts.link.url,
479
+ action_id: opts.link.isGithub ? "pm_slack_open_github" : "pm_slack_open_item",
480
+ },
481
+ ],
482
+ });
483
+ }
195
484
  const footerBits = [
196
485
  `pm item ${item.id} ${meta.verb}`,
197
486
  opts.channel ? `channel ${opts.channel}` : null,
@@ -200,9 +489,32 @@ function buildItemBlockKit(item, event, opts = {}) {
200
489
  type: "context",
201
490
  elements: [{ type: "mrkdwn", text: `🤖 pm-slack · ${footerBits.join(" · ")}` }],
202
491
  });
203
- const fallback = buildTextMessage(item, event, opts.channel);
492
+ const fallback = buildTextMessage(item, event, opts.channel, opts.mention);
204
493
  return { blocks, fallback };
205
494
  }
495
+ /**
496
+ * Build a ready-to-post SlackPayload for an item event in the requested format.
497
+ * - "blockkit": rich Block Kit blocks + plain-text fallback in `text`.
498
+ * - "text": plain mrkdwn only (no `blocks`), for minimal/legacy channels.
499
+ */
500
+ function buildItemPayload(item, event, format, opts = {}) {
501
+ if (format === "text") {
502
+ const text = buildTextMessage(item, event, opts.channel, opts.mention);
503
+ const body = opts.note && opts.note.trim() ? `${text}\n${opts.note.trim()}` : text;
504
+ return {
505
+ text: body,
506
+ mrkdwn: true,
507
+ ...(opts.channel ? { channel: opts.channel } : {}),
508
+ };
509
+ }
510
+ const { blocks, fallback } = buildItemBlockKit(item, event, opts);
511
+ return {
512
+ text: fallback,
513
+ blocks,
514
+ mrkdwn: true,
515
+ ...(opts.channel ? { channel: opts.channel } : {}),
516
+ };
517
+ }
206
518
  // ---------------------------------------------------------------------------
207
519
  // Slack HTTP POST
208
520
  // ---------------------------------------------------------------------------
@@ -246,6 +558,245 @@ function postToSlack(webhookUrl, payload) {
246
558
  });
247
559
  }
248
560
  // ---------------------------------------------------------------------------
561
+ // Digest: reading the pm store + activity aggregation
562
+ //
563
+ // The digest reads items directly from the pm store (`<pm_root>/<dir>/*.toon`
564
+ // and `*.json`) using a tiny scalar parser — we only need a handful of
565
+ // top-level fields (id/title/type/status/priority/timestamps/reasons). This
566
+ // avoids a runtime dependency on the pm SDK service layer (registerService is
567
+ // limited and corrupts output, #96) and keeps the package dependency-free.
568
+ // ---------------------------------------------------------------------------
569
+ /** Directories under a pm root that hold items, by convention. */
570
+ const ITEM_DIRS = ["tasks", "features", "issues", "epics", "stories", "bugs", "decisions", "items"];
571
+ /**
572
+ * Minimal parser for a stored pm item file. Handles the toon scalar form
573
+ * (`key: value`, optionally quoted) and JSON. Only top-level scalar fields are
574
+ * extracted; nested/array sections are ignored. Returns null when no id found.
575
+ */
576
+ function parseStoredItem(content, ext) {
577
+ if (ext === ".json") {
578
+ try {
579
+ const obj = JSON.parse(content);
580
+ const node = (obj.item ?? obj);
581
+ if (typeof node.id !== "string")
582
+ return null;
583
+ return node;
584
+ }
585
+ catch {
586
+ return null;
587
+ }
588
+ }
589
+ // toon: line-oriented `key: value`. Stop reading a key when it introduces a
590
+ // block/array (e.g. `notes[1]{...}:`); we only want flat scalars.
591
+ const out = {};
592
+ for (const rawLine of content.split(/\r?\n/)) {
593
+ const line = rawLine;
594
+ if (!line || /^\s/.test(line))
595
+ continue; // skip indented (nested) lines
596
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(line);
597
+ if (!m)
598
+ continue;
599
+ const key = m[1];
600
+ let value = m[2].trim();
601
+ if (value === "" || value === '""') {
602
+ out[key] = "";
603
+ continue;
604
+ }
605
+ // Strip surrounding double quotes (toon quotes strings with embedded chars).
606
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
607
+ value = value.slice(1, -1).replace(/\\"/g, '"');
608
+ }
609
+ out[key] = value;
610
+ }
611
+ if (typeof out.id !== "string")
612
+ return null;
613
+ if (typeof out.priority === "string" && /^\d+$/.test(out.priority)) {
614
+ out.priority = parseInt(out.priority, 10);
615
+ }
616
+ return out;
617
+ }
618
+ /** Read all items from a pm root. Best-effort: unreadable files are skipped. */
619
+ function readStoreItems(pmRoot) {
620
+ const items = [];
621
+ for (const dir of ITEM_DIRS) {
622
+ const full = path.join(pmRoot, dir);
623
+ let entries;
624
+ try {
625
+ entries = fs.readdirSync(full);
626
+ }
627
+ catch {
628
+ continue;
629
+ }
630
+ for (const name of entries) {
631
+ const ext = path.extname(name);
632
+ if (ext !== ".toon" && ext !== ".json")
633
+ continue;
634
+ try {
635
+ const content = fs.readFileSync(path.join(full, name), "utf8");
636
+ const item = parseStoredItem(content, ext);
637
+ if (item)
638
+ items.push(item);
639
+ }
640
+ catch {
641
+ // skip unreadable item
642
+ }
643
+ }
644
+ }
645
+ return items;
646
+ }
647
+ /**
648
+ * Resolve a `--since <date>` / `--days <n>` window into an epoch-ms cutoff.
649
+ * `since` (ISO date or datetime) wins when both are given; otherwise `days`
650
+ * back from `now`. Defaults to 7 days. Returns the cutoff in ms.
651
+ */
652
+ function resolveWindow(since, days, now = Date.now()) {
653
+ if (since) {
654
+ const t = Date.parse(since);
655
+ if (!Number.isNaN(t)) {
656
+ return { cutoffMs: t, label: `since ${since}` };
657
+ }
658
+ }
659
+ const d = days !== undefined && Number.isFinite(days) && days > 0 ? Math.floor(days) : 7;
660
+ return { cutoffMs: now - d * 24 * 60 * 60 * 1000, label: `last ${d} day${d === 1 ? "" : "s"}` };
661
+ }
662
+ function statusIsClosed(status) {
663
+ const s = (status ?? "").toLowerCase();
664
+ return s === "closed" || s === "done" || s === "resolved" || s === "complete" || s === "completed";
665
+ }
666
+ /**
667
+ * Aggregate store items into digest buckets for the window. An item counts as:
668
+ * - created if created_at >= cutoff
669
+ * - closed if it is in a closed/done status AND updated_at >= cutoff
670
+ * - blocked if status === blocked AND updated_at >= cutoff
671
+ * - in_progress if status === in_progress AND updated_at >= cutoff
672
+ * An item may appear in multiple buckets (e.g. created and blocked).
673
+ */
674
+ function aggregateDigest(items, cutoffMs, windowLabel) {
675
+ const buckets = {
676
+ created: [],
677
+ closed: [],
678
+ blocked: [],
679
+ in_progress: [],
680
+ };
681
+ for (const item of items) {
682
+ const created = item.created_at ? Date.parse(item.created_at) : NaN;
683
+ const updated = item.updated_at ? Date.parse(item.updated_at) : created;
684
+ const status = (item.status ?? "").toLowerCase();
685
+ if (!Number.isNaN(created) && created >= cutoffMs)
686
+ buckets.created.push(item);
687
+ if (statusIsClosed(status) && !Number.isNaN(updated) && updated >= cutoffMs)
688
+ buckets.closed.push(item);
689
+ if (status === "blocked" && !Number.isNaN(updated) && updated >= cutoffMs)
690
+ buckets.blocked.push(item);
691
+ if (status === "in_progress" && !Number.isNaN(updated) && updated >= cutoffMs)
692
+ buckets.in_progress.push(item);
693
+ }
694
+ const counts = {
695
+ created: buckets.created.length,
696
+ closed: buckets.closed.length,
697
+ blocked: buckets.blocked.length,
698
+ in_progress: buckets.in_progress.length,
699
+ };
700
+ const total = counts.created + counts.closed + counts.blocked + counts.in_progress;
701
+ return { windowLabel, cutoffMs, counts, buckets, total };
702
+ }
703
+ const DIGEST_BUCKET_META = {
704
+ created: { label: "Created", emoji: "🆕" },
705
+ closed: { label: "Closed", emoji: "✅" },
706
+ blocked: { label: "Blocked", emoji: "🚫" },
707
+ in_progress: { label: "In progress", emoji: "🔄" },
708
+ };
709
+ const DIGEST_ORDER = ["created", "closed", "in_progress", "blocked"];
710
+ /** A short "id title" line per item, capped so the digest stays readable. */
711
+ function digestItemLines(items, max = 5) {
712
+ const lines = items.slice(0, max).map((i) => `• ${i.id} — ${i.title ?? "(untitled)"}`);
713
+ if (items.length > max)
714
+ lines.push(`• …and ${items.length - max} more`);
715
+ return lines;
716
+ }
717
+ function buildDigestText(summary, channel) {
718
+ const head = `*pm activity digest* (${summary.windowLabel}) — ${summary.total} update${summary.total === 1 ? "" : "s"}`;
719
+ const parts = [head];
720
+ for (const bucket of DIGEST_ORDER) {
721
+ const items = summary.buckets[bucket];
722
+ if (items.length === 0)
723
+ continue;
724
+ const meta = DIGEST_BUCKET_META[bucket];
725
+ parts.push(`\n${meta.emoji} *${meta.label}* (${items.length})`);
726
+ parts.push(digestItemLines(items).join("\n"));
727
+ }
728
+ if (summary.total === 0)
729
+ parts.push("\n_No activity in this window._");
730
+ if (channel)
731
+ parts.push(`\n_Channel: ${channel}_`);
732
+ return parts.join("\n");
733
+ }
734
+ function buildDigestBlockKit(summary, channel) {
735
+ const blocks = [];
736
+ blocks.push({
737
+ type: "header",
738
+ text: { type: "plain_text", text: `📊 pm activity digest`, emoji: true },
739
+ });
740
+ blocks.push({
741
+ type: "context",
742
+ elements: [
743
+ {
744
+ type: "mrkdwn",
745
+ text: `${summary.windowLabel} · ${summary.total} update${summary.total === 1 ? "" : "s"}`,
746
+ },
747
+ ],
748
+ });
749
+ // Counts grid.
750
+ const fields = DIGEST_ORDER.map((bucket) => {
751
+ const meta = DIGEST_BUCKET_META[bucket];
752
+ return { type: "mrkdwn", text: `${meta.emoji} *${meta.label}:*\n${summary.counts[bucket]}` };
753
+ });
754
+ blocks.push({ type: "section", fields });
755
+ for (const bucket of DIGEST_ORDER) {
756
+ const items = summary.buckets[bucket];
757
+ if (items.length === 0)
758
+ continue;
759
+ const meta = DIGEST_BUCKET_META[bucket];
760
+ blocks.push({ type: "divider" });
761
+ blocks.push({
762
+ type: "section",
763
+ text: {
764
+ type: "mrkdwn",
765
+ text: `${meta.emoji} *${meta.label}* (${items.length})\n${digestItemLines(items).join("\n")}`,
766
+ },
767
+ });
768
+ }
769
+ if (summary.total === 0) {
770
+ blocks.push({
771
+ type: "section",
772
+ text: { type: "mrkdwn", text: "_No activity in this window._" },
773
+ });
774
+ }
775
+ const footerBits = [`pm digest · ${summary.windowLabel}`, channel ? `channel ${channel}` : null].filter(Boolean);
776
+ blocks.push({
777
+ type: "context",
778
+ elements: [{ type: "mrkdwn", text: `🤖 pm-slack · ${footerBits.join(" · ")}` }],
779
+ });
780
+ const fallback = buildDigestText(summary, channel);
781
+ return { blocks, fallback };
782
+ }
783
+ function buildDigestPayload(summary, format, channel) {
784
+ if (format === "text") {
785
+ return {
786
+ text: buildDigestText(summary, channel),
787
+ mrkdwn: true,
788
+ ...(channel ? { channel } : {}),
789
+ };
790
+ }
791
+ const { blocks, fallback } = buildDigestBlockKit(summary, channel);
792
+ return {
793
+ text: fallback,
794
+ blocks,
795
+ mrkdwn: true,
796
+ ...(channel ? { channel } : {}),
797
+ };
798
+ }
799
+ // ---------------------------------------------------------------------------
249
800
  // Event detection (afterCommand hook)
250
801
  // ---------------------------------------------------------------------------
251
802
  /** Commands that create a new item */
@@ -254,23 +805,74 @@ const CREATE_COMMANDS = new Set(["add", "create", "new"]);
254
805
  const CLOSE_COMMANDS = new Set(["close", "done", "complete", "finish", "resolve"]);
255
806
  /** Commands that update an item's status or attributes */
256
807
  const UPDATE_COMMANDS = new Set(["update", "edit", "set", "status"]);
808
+ /** Lifecycle-alias commands that move an item to in_progress (start). */
809
+ const START_COMMANDS = new Set(["claim", "start-task", "start", "begin"]);
810
+ /** Lifecycle-alias commands that move an item back to open (e.g. pause). */
811
+ const OPEN_COMMANDS = new Set(["pause-task", "release", "reopen", "publish"]);
812
+ /** Map a (normalized) status string to the EventKind for a transition into it. */
813
+ function statusToEvent(status) {
814
+ switch ((status ?? "").toLowerCase()) {
815
+ case "blocked":
816
+ return "block";
817
+ case "canceled":
818
+ case "cancelled":
819
+ return "cancel";
820
+ case "closed":
821
+ case "done":
822
+ case "resolved":
823
+ case "complete":
824
+ case "completed":
825
+ return "close";
826
+ case "in_progress":
827
+ case "in-progress":
828
+ return "start";
829
+ case "open":
830
+ return "open";
831
+ default:
832
+ return null;
833
+ }
834
+ }
835
+ /**
836
+ * Detect the lifecycle event for a completed command. Strategy:
837
+ * 1. Create/close commands map directly.
838
+ * 2. Status-changing commands (update/set/lifecycle aliases) derive the event
839
+ * from the *resulting* status (preferring the explicit --status option,
840
+ * falling back to the result item's status).
841
+ * 3. When the result carries the prior status (`previousStatus`), refine
842
+ * open→reopen (from a closed/canceled state) and open→unblock (from
843
+ * blocked) so terminal-recovery transitions are distinguishable.
844
+ */
257
845
  function detectEvent(ctx) {
258
846
  const cmd = ctx.command?.toLowerCase() ?? "";
259
847
  if (CREATE_COMMANDS.has(cmd))
260
848
  return "create";
261
849
  if (CLOSE_COMMANDS.has(cmd))
262
850
  return "close";
263
- if (UPDATE_COMMANDS.has(cmd)) {
264
- const newStatus = ctx.options?.["status"] ??
265
- ctx.result?.item?.status ??
266
- "";
267
- if (newStatus.toLowerCase() === "blocked")
268
- return "block";
269
- const resultItem = ctx.result?.item;
270
- if (resultItem?.status?.toLowerCase() === "blocked")
271
- return "block";
851
+ const result = ctx.result;
852
+ const resultStatus = result?.item?.status;
853
+ const prevStatus = (result?.previousStatus ?? result?.previous_status ?? "").toLowerCase();
854
+ // Lifecycle aliases imply a target status even without --status.
855
+ let event = null;
856
+ if (START_COMMANDS.has(cmd))
857
+ event = "start";
858
+ else if (OPEN_COMMANDS.has(cmd))
859
+ event = cmd === "reopen" ? "reopen" : "open";
860
+ if (!event && UPDATE_COMMANDS.has(cmd)) {
861
+ const optStatus = ctx.options?.["status"];
862
+ event = statusToEvent(optStatus) ?? statusToEvent(resultStatus);
272
863
  }
273
- return null;
864
+ if (!event)
865
+ return null;
866
+ // Refine an "open" transition using the prior status when available:
867
+ // blocked -> open => unblock
868
+ // closed/canceled -> open => reopen
869
+ if (event === "open" && prevStatus) {
870
+ if (prevStatus === "blocked")
871
+ return "unblock";
872
+ if (["closed", "canceled", "cancelled", "done", "resolved"].includes(prevStatus))
873
+ return "reopen";
874
+ }
875
+ return event;
274
876
  }
275
877
  function extractItem(ctx) {
276
878
  const result = ctx.result;
@@ -289,7 +891,7 @@ function extractItem(ctx) {
289
891
  // ---------------------------------------------------------------------------
290
892
  export default defineExtension({
291
893
  name: "pm-slack",
292
- version: "2026.6.2",
894
+ version: "2026.6.4",
293
895
  activate(api) {
294
896
  // -----------------------------------------------------------------------
295
897
  // afterCommand hook — fires after every pm-cli command completes.
@@ -322,15 +924,22 @@ export default defineExtension({
322
924
  console.error(`[pm-slack] Item priority ${item.priority} below minimum ${config.minPriority} — skipping`);
323
925
  return;
324
926
  }
325
- const { blocks, fallback } = buildItemBlockKit(item, event, {
326
- channel: config.channel,
327
- });
328
- await postToSlack(config.webhookUrl, {
329
- text: fallback,
330
- blocks,
331
- mrkdwn: true,
332
- channel: config.channel,
927
+ // Resolve destination via routing rules (falls back to defaults).
928
+ const target = selectRoute(event, item, config.routes, config.webhookUrl, config.channel);
929
+ if (!target) {
930
+ console.error(`[pm-slack] No webhook resolved for event "${event}" — skipping`);
931
+ return;
932
+ }
933
+ const mention = config.mentionAssignee
934
+ ? resolveAssigneeMention(item, config.assigneeMap)
935
+ : undefined;
936
+ const link = resolveItemUrl(item);
937
+ const payload = buildItemPayload(item, event, config.format, {
938
+ channel: target.channel,
939
+ mention,
940
+ link,
333
941
  });
942
+ await postToSlack(target.webhookUrl, payload);
334
943
  console.error(`[pm-slack] Notification sent for event "${event}" on item ${item.id}`);
335
944
  }
336
945
  catch (err) {
@@ -386,9 +995,14 @@ export default defineExtension({
386
995
  { long: "--title", value_name: "title", description: "Title shown in the Block Kit header (defaults to the --text first line)" },
387
996
  { long: "--channel", value_name: "name", description: "Channel name shown in the message (e.g. #pm-alerts). Overrides PM_SLACK_CHANNEL." },
388
997
  { long: "--thread", value_name: "ts", description: "Slack thread timestamp (thread_ts) to reply into a thread" },
389
- { long: "--on", value_name: "events", description: "Comma list of lifecycle events for the message template (create,close,block). Default: create" },
998
+ { long: "--on", value_name: "events", description: "Comma list of lifecycle events for the message template (create,close,block,cancel,open,start,unblock,reopen). Default: create" },
999
+ { long: "--format", value_name: "fmt", description: "Message format: blockkit (default) or text. Overrides PM_SLACK_FORMAT." },
1000
+ { long: "--assignee", value_name: "name", description: "Assignee name shown on the message (mapped to a Slack @mention via PM_SLACK_ASSIGNEE_MAP)" },
1001
+ { long: "--mention-assignee", description: "Resolve the assignee to a Slack @mention using PM_SLACK_ASSIGNEE_MAP" },
1002
+ { long: "--url", value_name: "url", description: "Add a Block Kit action button linking to this URL (e.g. a GitHub item)" },
390
1003
  { long: "--webhook", value_name: "url", description: "Slack incoming webhook URL (overrides PM_SLACK_WEBHOOK env var)" },
391
- { long: "--dry-run", description: "Print the message and Block Kit payload without posting to Slack" },
1004
+ { long: "--dry-run", description: "Print the message and payload without posting to Slack" },
1005
+ { long: "--json", description: "Emit the result/payload as JSON (machine-readable)" },
392
1006
  ],
393
1007
  async run(ctx) {
394
1008
  const options = ctx.options ?? {};
@@ -396,6 +1010,8 @@ export default defineExtension({
396
1010
  const webhookUrl = readStrOption(options, "webhook") ?? process.env.PM_SLACK_WEBHOOK ?? "";
397
1011
  const channel = (readStrOption(options, "channel") ?? process.env.PM_SLACK_CHANNEL?.trim()) || undefined;
398
1012
  const threadTs = readStrOption(options, "thread");
1013
+ const asJson = ctx.global?.json === true || readBoolOption(options, "json");
1014
+ const format = parseFormat(readStrOption(options, "format") ?? process.env.PM_SLACK_FORMAT);
399
1015
  // `--on` selects the message template. First event wins for the header verb.
400
1016
  const events = parseEvents(readStrOption(options, "on") ?? "create");
401
1017
  const event = (ALL_EVENTS.find((e) => events.has(e)) ?? "create");
@@ -404,27 +1020,35 @@ export default defineExtension({
404
1020
  if (!title) {
405
1021
  throw new CommandError("Provide a --title or --text to send (e.g. `pm slack notify --text 'hello'`)", EXIT_CODE.USAGE);
406
1022
  }
407
- // Synthesize a minimal item from the flags so we can reuse the Block
408
- // Kit builder. This command is for ad-hoc posts, not item lookups.
409
- const item = { id: "manual", title, type: "Note" };
410
- const { blocks, fallback } = buildItemBlockKit(item, event, {
411
- channel,
412
- note: text && text !== title ? text : undefined,
413
- });
1023
+ // Synthesize a minimal item from the flags so we can reuse the
1024
+ // message builder. This command is for ad-hoc posts, not item lookups.
1025
+ const assignee = readStrOption(options, "assignee");
1026
+ const url = readStrOption(options, "url");
1027
+ const item = { id: "manual", title, type: "Note", ...(assignee ? { assignee } : {}) };
1028
+ const assigneeMap = parseAssigneeMap(process.env.PM_SLACK_ASSIGNEE_MAP);
1029
+ // Mention when explicitly requested, or implicitly when an assignee
1030
+ // maps to a Slack id (so the @mention is opt-in but ergonomic).
1031
+ const wantMention = readBoolOption(options, "mention-assignee") || assigneeMap.size > 0;
1032
+ const mention = wantMention ? resolveAssigneeMention(item, assigneeMap) : undefined;
1033
+ const link = resolveItemUrl(item, url);
414
1034
  const payload = {
415
- text: fallback,
416
- blocks,
417
- mrkdwn: true,
418
- ...(channel ? { channel } : {}),
1035
+ ...buildItemPayload(item, event, format, {
1036
+ channel,
1037
+ note: text && text !== title ? text : undefined,
1038
+ mention,
1039
+ link,
1040
+ }),
419
1041
  ...(threadTs ? { thread_ts: threadTs } : {}),
420
1042
  };
421
1043
  if (dryRun) {
422
- console.error("--- DRY RUN (message not posted) ---");
423
- process.stdout.write(fallback + "\n");
424
- console.error("--- Block Kit payload ---");
425
- process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
426
- console.error("--- END ---");
427
- return { dryRun: true, event, channel, thread_ts: threadTs, blocks, fallback };
1044
+ if (!asJson) {
1045
+ console.error("--- DRY RUN (message not posted) ---");
1046
+ process.stdout.write(payload.text + "\n");
1047
+ console.error(`--- ${format} payload ---`);
1048
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
1049
+ console.error("--- END ---");
1050
+ }
1051
+ return { dryRun: true, event, format, channel, thread_ts: threadTs, payload };
428
1052
  }
429
1053
  if (!webhookUrl) {
430
1054
  // Graceful no-op: warn and exit 0 so a missing webhook never blocks
@@ -445,17 +1069,167 @@ export default defineExtension({
445
1069
  return { posted: true, event, channel, thread_ts: threadTs };
446
1070
  },
447
1071
  });
1072
+ // ---------------------------------------------------------------------
1073
+ // command — `pm slack test` : build and PRINT a sample notification in
1074
+ // the chosen format WITHOUT posting. Fully offline; the package is
1075
+ // testable with no Slack credentials. Honors --json for machine reads.
1076
+ // ---------------------------------------------------------------------
1077
+ api.registerCommand({
1078
+ name: "slack test",
1079
+ description: "Preview a sample pm-slack notification in the chosen format without posting (offline)",
1080
+ intent: "Preview Slack notification formatting offline (no webhook, no network)",
1081
+ examples: [
1082
+ "pm slack test --format blockkit",
1083
+ "pm slack test --format text --on close",
1084
+ "pm slack test --on block --json",
1085
+ "pm slack test --on cancel",
1086
+ "PM_SLACK_ASSIGNEE_MAP='alice=U123' pm slack test --on start --mention-assignee",
1087
+ ],
1088
+ flags: [
1089
+ { long: "--format", value_name: "fmt", description: "Message format: blockkit (default) or text. Overrides PM_SLACK_FORMAT." },
1090
+ { long: "--on", value_name: "event", description: "Lifecycle event to preview (create,close,block,cancel,open,start,unblock,reopen). Default: create" },
1091
+ { long: "--title", value_name: "title", description: "Sample item title (default: a representative example)" },
1092
+ { long: "--channel", value_name: "name", description: "Channel hint to show in the preview (overrides PM_SLACK_CHANNEL)" },
1093
+ { long: "--mention-assignee", description: "Resolve the sample assignee to a Slack @mention via PM_SLACK_ASSIGNEE_MAP" },
1094
+ { long: "--url", value_name: "url", description: "Add a Block Kit action button linking to this URL (default: a sample GitHub URL)" },
1095
+ { long: "--json", description: "Emit the preview payload as JSON" },
1096
+ { long: "--dry-run", description: "Accepted for symmetry; this command never posts." },
1097
+ ],
1098
+ async run(ctx) {
1099
+ const options = ctx.options ?? {};
1100
+ // `--json` is consumed by pm as a global flag, so prefer global.json;
1101
+ // also honor a local --json for robustness across runtimes.
1102
+ const asJson = ctx.global?.json === true || readBoolOption(options, "json");
1103
+ const format = parseFormat(readStrOption(options, "format") ?? process.env.PM_SLACK_FORMAT);
1104
+ const channel = (readStrOption(options, "channel") ?? process.env.PM_SLACK_CHANNEL?.trim()) || undefined;
1105
+ const events = parseEvents(readStrOption(options, "on") ?? "create");
1106
+ const event = (ALL_EVENTS.find((e) => events.has(e)) ?? "create");
1107
+ const t = readStrOption(options, "title");
1108
+ // A representative sample item per event so the preview is realistic.
1109
+ const sample = {
1110
+ create: { id: "pm-1a2b", title: t ?? "Add OAuth login flow", type: "Feature", priority: 2, status: "open", author: "alice", assignee: "alice", github_url: "https://github.com/unbraind/pm-slack/issues/1" },
1111
+ close: { id: "pm-3c4d", title: t ?? "Fix login redirect bug", type: "Issue", priority: 1, status: "closed", author: "bob", assignee: "bob", close_reason: "Fixed in commit abc123", github_url: "https://github.com/unbraind/pm-slack/issues/2" },
1112
+ block: { id: "pm-5e6f", title: t ?? "Dashboard redesign", type: "Epic", priority: 2, status: "blocked", author: "carol", assignee: "carol", blocked_reason: "Waiting on design approval" },
1113
+ cancel: { id: "pm-7g8h", title: t ?? "Legacy export pipeline", type: "Task", priority: 3, status: "canceled", author: "dave", assignee: "dave", close_reason: "Superseded by new pipeline" },
1114
+ open: { id: "pm-9i0j", title: t ?? "Publish onboarding guide", type: "Task", priority: 3, status: "open", author: "erin", assignee: "erin" },
1115
+ start: { id: "pm-1k2l", title: t ?? "Implement rate limiter", type: "Feature", priority: 2, status: "in_progress", author: "frank", assignee: "frank", github_url: "https://github.com/unbraind/pm-slack/pull/3" },
1116
+ unblock: { id: "pm-3m4n", title: t ?? "Payment reconciliation", type: "Issue", priority: 1, status: "open", author: "grace", assignee: "grace" },
1117
+ reopen: { id: "pm-5o6p", title: t ?? "Flaky integration test", type: "Bug", priority: 2, status: "open", author: "heidi", assignee: "heidi" },
1118
+ };
1119
+ const item = sample[event];
1120
+ const assigneeMap = parseAssigneeMap(process.env.PM_SLACK_ASSIGNEE_MAP);
1121
+ const wantMention = readBoolOption(options, "mention-assignee") || assigneeMap.size > 0;
1122
+ const mention = wantMention ? resolveAssigneeMention(item, assigneeMap) : undefined;
1123
+ const link = resolveItemUrl(item, readStrOption(options, "url"));
1124
+ const payload = buildItemPayload(item, event, format, { channel, mention, link });
1125
+ // In JSON mode, pm renders the returned object — don't also write to
1126
+ // stdout (that would corrupt the JSON stream). In human mode, print a
1127
+ // readable preview to stdout/stderr.
1128
+ if (!asJson) {
1129
+ console.error(`--- PREVIEW (${format}, event=${event}) — nothing posted ---`);
1130
+ process.stdout.write(payload.text + "\n");
1131
+ console.error(`--- ${format} payload ---`);
1132
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
1133
+ console.error("--- END ---");
1134
+ }
1135
+ return { preview: true, event, format, channel, payload };
1136
+ },
1137
+ });
1138
+ // ---------------------------------------------------------------------
1139
+ // command — `pm slack digest` : summarize recent activity (created /
1140
+ // closed / blocked / in-progress) over a --since/--days window into a
1141
+ // single notification. Reads the pm store directly; --dry-run prints
1142
+ // without posting.
1143
+ // ---------------------------------------------------------------------
1144
+ api.registerCommand({
1145
+ name: "slack digest",
1146
+ description: "Post (or preview) a single summary of recent pm activity over a time window",
1147
+ intent: "Summarize recent pm activity (created/closed/blocked/in-progress) into one Slack message",
1148
+ examples: [
1149
+ "pm slack digest --days 7 --dry-run",
1150
+ "pm slack digest --since 2026-06-01 --format text --dry-run",
1151
+ "pm slack digest --days 1 --format blockkit --json --dry-run",
1152
+ ],
1153
+ flags: [
1154
+ { long: "--since", value_name: "date", description: "Only include activity at/after this ISO date (e.g. 2026-06-01)" },
1155
+ { long: "--days", value_name: "n", description: "Look back N days from now (default 7; ignored when --since is given)" },
1156
+ { long: "--format", value_name: "fmt", description: "Message format: blockkit (default) or text. Overrides PM_SLACK_FORMAT." },
1157
+ { long: "--channel", value_name: "name", description: "Channel to post to (overrides PM_SLACK_CHANNEL)" },
1158
+ { long: "--webhook", value_name: "url", description: "Slack incoming webhook URL (overrides PM_SLACK_WEBHOOK)" },
1159
+ { long: "--dry-run", description: "Build and print the digest without posting to Slack" },
1160
+ { long: "--json", description: "Emit the digest summary/payload as JSON" },
1161
+ ],
1162
+ async run(ctx) {
1163
+ const options = ctx.options ?? {};
1164
+ const dryRun = readBoolOption(options, "dry-run");
1165
+ const asJson = ctx.global?.json === true || readBoolOption(options, "json");
1166
+ const format = parseFormat(readStrOption(options, "format") ?? process.env.PM_SLACK_FORMAT);
1167
+ const channel = (readStrOption(options, "channel") ?? process.env.PM_SLACK_CHANNEL?.trim()) || undefined;
1168
+ const webhookUrl = readStrOption(options, "webhook") ?? process.env.PM_SLACK_WEBHOOK ?? "";
1169
+ const since = readStrOption(options, "since");
1170
+ const daysStr = readStrOption(options, "days");
1171
+ const days = daysStr !== undefined ? parseInt(daysStr, 10) : undefined;
1172
+ if (daysStr !== undefined && (days === undefined || Number.isNaN(days) || days <= 0)) {
1173
+ throw new CommandError(`--days must be a positive integer (got "${daysStr}")`, EXIT_CODE.USAGE);
1174
+ }
1175
+ const pmRoot = ctx.pm_root ?? path.join(process.cwd(), ".agents", "pm");
1176
+ const { cutoffMs, label } = resolveWindow(since, days);
1177
+ const items = readStoreItems(pmRoot);
1178
+ const summary = aggregateDigest(items, cutoffMs, label);
1179
+ const payload = buildDigestPayload(summary, format, channel);
1180
+ if (dryRun) {
1181
+ if (!asJson) {
1182
+ console.error(`--- DRY RUN digest (${format}, ${label}) — nothing posted ---`);
1183
+ process.stdout.write(payload.text + "\n");
1184
+ console.error(`--- ${format} payload ---`);
1185
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
1186
+ console.error("--- END ---");
1187
+ }
1188
+ return { dryRun: true, format, channel, counts: summary.counts, total: summary.total, window: label, payload };
1189
+ }
1190
+ // Real post requested: a webhook is mandatory. Fail cleanly (exit 1).
1191
+ if (!webhookUrl) {
1192
+ throw new CommandError("No Slack webhook configured: set PM_SLACK_WEBHOOK or pass --webhook (or use --dry-run to preview).", EXIT_CODE.GENERIC_FAILURE);
1193
+ }
1194
+ try {
1195
+ await postToSlack(webhookUrl, payload);
1196
+ }
1197
+ catch (err) {
1198
+ const message = err instanceof Error ? err.message : String(err);
1199
+ throw new CommandError(`Failed to post digest to Slack: ${message}`, EXIT_CODE.GENERIC_FAILURE);
1200
+ }
1201
+ return { posted: true, format, channel, counts: summary.counts, total: summary.total, window: label };
1202
+ },
1203
+ });
448
1204
  }
449
1205
  },
450
1206
  });
451
1207
  // Exported for unit tests (Block Kit builder + helpers).
452
1208
  export const __test__ = {
453
1209
  buildItemBlockKit,
1210
+ buildItemPayload,
454
1211
  buildTextMessage,
455
1212
  parseEvents,
1213
+ normalizeEvent,
1214
+ parseFormat,
1215
+ parseRoutes,
1216
+ ruleMatches,
1217
+ selectRoute,
456
1218
  detectEvent,
1219
+ statusToEvent,
457
1220
  extractItem,
458
1221
  meetsMinPriority,
1222
+ parseAssigneeMap,
1223
+ resolveAssigneeMention,
1224
+ resolveItemUrl,
1225
+ isHttpUrl,
1226
+ EVENT_META,
1227
+ parseStoredItem,
1228
+ resolveWindow,
1229
+ aggregateDigest,
1230
+ buildDigestText,
1231
+ buildDigestBlockKit,
1232
+ buildDigestPayload,
459
1233
  EXIT_CODE,
460
1234
  CommandError,
461
1235
  };