pm-slack-standup 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
@@ -18,7 +18,7 @@ const EXIT_CODE = {
18
18
  USAGE: 2,
19
19
  NOT_FOUND: 3,
20
20
  };
21
- class CommandError extends Error {
21
+ export class CommandError extends Error {
22
22
  exitCode;
23
23
  constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE) {
24
24
  super(message);
@@ -26,6 +26,12 @@ class CommandError extends Error {
26
26
  this.exitCode = exitCode;
27
27
  }
28
28
  }
29
+ export const ALL_SECTIONS = [
30
+ "in_progress",
31
+ "blocked",
32
+ "done",
33
+ "up_next",
34
+ ];
29
35
  // ---------------------------------------------------------------------------
30
36
  // Option helpers
31
37
  // ---------------------------------------------------------------------------
@@ -37,7 +43,7 @@ class CommandError extends Error {
37
43
  function camelCase(key) {
38
44
  return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
39
45
  }
40
- function readBoolOption(options, key) {
46
+ export function readBoolOption(options, key) {
41
47
  for (const candidate of [key, camelCase(key)]) {
42
48
  const value = options[candidate];
43
49
  if (typeof value === "boolean")
@@ -52,7 +58,7 @@ function readBoolOption(options, key) {
52
58
  }
53
59
  return false;
54
60
  }
55
- function readStrOption(options, key) {
61
+ export function readStrOption(options, key) {
56
62
  for (const candidate of [key, camelCase(key)]) {
57
63
  const value = options[candidate];
58
64
  if (typeof value === "string" && value.trim().length > 0) {
@@ -66,7 +72,7 @@ function readStrOption(options, key) {
66
72
  * Accepts `author=@handle,other=@h2` (commas) or semicolon separators.
67
73
  * A leading `@` on the handle is optional and normalized on.
68
74
  */
69
- function parseMentionMap(spec) {
75
+ export function parseMentionMap(spec) {
70
76
  const map = {};
71
77
  if (!spec)
72
78
  return map;
@@ -84,6 +90,176 @@ function parseMentionMap(spec) {
84
90
  }
85
91
  return map;
86
92
  }
93
+ /**
94
+ * Normalize a `--format` value. Accepts the four public formats plus the
95
+ * legacy `text` alias (== `plain`). Unknown values raise a USAGE CommandError.
96
+ */
97
+ export function parseFormat(raw) {
98
+ if (raw == null)
99
+ return "slack";
100
+ const v = raw.trim().toLowerCase();
101
+ if (v === "" || v === "slack")
102
+ return "slack";
103
+ if (v === "blockkit" || v === "block-kit" || v === "blocks")
104
+ return "blockkit";
105
+ if (v === "markdown" || v === "md")
106
+ return "markdown";
107
+ if (v === "plain" || v === "text" || v === "txt")
108
+ return "plain";
109
+ throw new CommandError(`Unknown --format '${raw}'. Valid: slack | blockkit | markdown | plain.`, EXIT_CODE.USAGE);
110
+ }
111
+ export function parseGroupBy(raw) {
112
+ if (raw == null)
113
+ return "status";
114
+ const v = raw.trim().toLowerCase();
115
+ if (v === "" || v === "status")
116
+ return "status";
117
+ if (v === "assignee" || v === "owner")
118
+ return "assignee";
119
+ if (v === "sprint")
120
+ return "sprint";
121
+ if (v === "type")
122
+ return "type";
123
+ throw new CommandError(`Unknown --group-by '${raw}'. Valid: status | assignee | sprint | type.`, EXIT_CODE.USAGE);
124
+ }
125
+ const SECTION_ALIASES = {
126
+ in_progress: "in_progress",
127
+ "in-progress": "in_progress",
128
+ wip: "in_progress",
129
+ progress: "in_progress",
130
+ blocked: "blocked",
131
+ done: "done",
132
+ closed: "done",
133
+ up_next: "up_next",
134
+ "up-next": "up_next",
135
+ upnext: "up_next",
136
+ next: "up_next",
137
+ };
138
+ /**
139
+ * Parse a `--sections` spec (comma/semicolon list) into an ordered, de-duped
140
+ * list of section keys. Empty spec β†’ all sections in default order. An
141
+ * unknown token is a USAGE error rather than a silent drop.
142
+ */
143
+ export function parseSections(spec) {
144
+ if (!spec || !spec.trim())
145
+ return [...ALL_SECTIONS];
146
+ const out = [];
147
+ for (const raw of spec.split(/[,;]/)) {
148
+ const token = raw.trim().toLowerCase();
149
+ if (!token)
150
+ continue;
151
+ const key = SECTION_ALIASES[token];
152
+ if (!key) {
153
+ throw new CommandError(`Unknown --sections value '${raw.trim()}'. Valid: in_progress | blocked | done | up_next.`, EXIT_CODE.USAGE);
154
+ }
155
+ if (!out.includes(key))
156
+ out.push(key);
157
+ }
158
+ return out.length > 0 ? out : [...ALL_SECTIONS];
159
+ }
160
+ /**
161
+ * Parse a `--section-labels` spec overriding section titles (and optionally
162
+ * an emoji). Accepts `key=Label,other=Label2` (comma/semicolon separated).
163
+ * The label value may itself lead with an emoji + space, e.g.
164
+ * `blocked=πŸ”₯ On Fire` sets emoji "πŸ”₯" and title "On Fire"; a label with no
165
+ * leading emoji keeps the section's default emoji and only changes the title.
166
+ * Keys use the same aliases as `--sections` (wip→in_progress, etc.).
167
+ * Unknown keys are a USAGE error rather than a silent drop.
168
+ */
169
+ export function parseSectionLabels(spec) {
170
+ const out = {};
171
+ if (!spec || !spec.trim())
172
+ return out;
173
+ for (const pair of spec.split(/[,;]/)) {
174
+ const eq = pair.indexOf("=");
175
+ if (eq < 0)
176
+ continue;
177
+ const rawKey = pair.slice(0, eq).trim().toLowerCase();
178
+ const rawVal = pair.slice(eq + 1).trim();
179
+ if (!rawKey || !rawVal)
180
+ continue;
181
+ const key = SECTION_ALIASES[rawKey];
182
+ if (!key) {
183
+ throw new CommandError(`Unknown --section-labels key '${rawKey}'. Valid: in_progress | blocked | done | up_next.`, EXIT_CODE.USAGE);
184
+ }
185
+ // Split on the first space; treat the head as an emoji override only
186
+ // when it carries a non-ASCII codepoint (emoji/symbol). A plain ASCII
187
+ // word is part of the title, so the section keeps its default emoji.
188
+ const spaceIdx = rawVal.indexOf(" ");
189
+ const override = {};
190
+ if (spaceIdx > 0) {
191
+ const head = rawVal.slice(0, spaceIdx);
192
+ const tail = rawVal.slice(spaceIdx + 1).trim();
193
+ const headHasNonAscii = [...head].some((ch) => ch.codePointAt(0) > 127);
194
+ if (tail && headHasNonAscii) {
195
+ override.emoji = head;
196
+ override.title = tail;
197
+ }
198
+ else {
199
+ override.title = rawVal;
200
+ }
201
+ }
202
+ else {
203
+ override.title = rawVal;
204
+ }
205
+ out[key] = override;
206
+ }
207
+ return out;
208
+ }
209
+ /**
210
+ * Parse a `--channels` spec (comma/semicolon list) into an ordered, de-duped
211
+ * list of channel targets. Each target is either a Slack channel name
212
+ * (e.g. `#team-eng`) or a full webhook URL β€” multi-channel posting accepts
213
+ * both (a name is shown in the message; a URL is POSTed to). Empty β†’ [].
214
+ */
215
+ export function parseChannels(spec) {
216
+ if (!spec || !spec.trim())
217
+ return [];
218
+ const out = [];
219
+ for (const raw of spec.split(/[,;]/)) {
220
+ const token = raw.trim();
221
+ if (token && !out.includes(token))
222
+ out.push(token);
223
+ }
224
+ return out;
225
+ }
226
+ /** True when a channel token is a full webhook URL rather than a name. */
227
+ export function isWebhookUrl(token) {
228
+ return /^https?:\/\//i.test(token.trim());
229
+ }
230
+ /**
231
+ * Resolve the "recently closed" window start (ms epoch) from `--since` and/or
232
+ * `--days`. `--since` is an explicit ISO date/time; `--days <n>` is N days
233
+ * before now. If both are given the *later* (more restrictive) bound wins.
234
+ * Returns NaN when neither is set (no windowing). Invalid input β†’ USAGE error.
235
+ */
236
+ export function resolveSinceMs(since, days, now = Date.now()) {
237
+ let bound = NaN;
238
+ if (since != null) {
239
+ const ms = Date.parse(since);
240
+ if (isNaN(ms)) {
241
+ throw new CommandError(`Invalid --since value '${since}' (expected an ISO date/time).`, EXIT_CODE.USAGE);
242
+ }
243
+ bound = ms;
244
+ }
245
+ if (days != null) {
246
+ if (!Number.isFinite(days) || days < 0) {
247
+ throw new CommandError(`Invalid --days value '${days}' (expected a non-negative number).`, EXIT_CODE.USAGE);
248
+ }
249
+ const daysBound = now - days * 86_400_000;
250
+ bound = isNaN(bound) ? daysBound : Math.max(bound, daysBound);
251
+ }
252
+ return bound;
253
+ }
254
+ export function parseDays(raw) {
255
+ if (raw == null || raw.trim() === "")
256
+ return undefined;
257
+ const n = Number(raw.trim());
258
+ if (!Number.isFinite(n)) {
259
+ throw new CommandError(`Invalid --days value '${raw}' (expected a number).`, EXIT_CODE.USAGE);
260
+ }
261
+ return n;
262
+ }
87
263
  // ---------------------------------------------------------------------------
88
264
  // Data fetch
89
265
  // ---------------------------------------------------------------------------
@@ -92,7 +268,7 @@ function parseMentionMap(spec) {
92
268
  * status locally. This is a single pm invocation (vs. four list-by-status
93
269
  * calls) and gives us bodies + assignee + timestamps for grouping/windowing.
94
270
  */
95
- function fetchAllItems(pmRoot) {
271
+ export function fetchAllItems(pmRoot) {
96
272
  const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json", "--include-body"], { encoding: "utf-8" });
97
273
  if (result.error || result.status !== 0) {
98
274
  console.error(`pm list-all failed: ${result.stderr ?? result.error?.message ?? ""}`);
@@ -113,29 +289,152 @@ const DONE_STATUSES = new Set(["closed", "done", "complete", "completed"]);
113
289
  function statusOf(item) {
114
290
  return (item.status ?? "").trim().toLowerCase();
115
291
  }
292
+ /**
293
+ * True when an item's last activity falls within the [sinceMs, now] window.
294
+ * NaN sinceMs means "no window" β†’ always true.
295
+ */
296
+ export function withinWindow(item, sinceMs) {
297
+ if (isNaN(sinceMs))
298
+ return true;
299
+ const ts = Date.parse(item.updated_at ?? item.created_at ?? "");
300
+ return isNaN(ts) ? false : ts >= sinceMs;
301
+ }
302
+ /**
303
+ * True when an item carries a `blocked_by` dependency, regardless of its
304
+ * status. pm surfaces this either as a top-level `blocked_by` string (item ID
305
+ * or free-text reason) or as one/more `dependencies` entries with
306
+ * `kind: "blocked_by"`. Used to surface impediments that are NOT explicitly
307
+ * status=blocked under the Blocked section.
308
+ */
309
+ export function hasBlockedByDep(item) {
310
+ const top = item.blocked_by;
311
+ if (typeof top === "string" && top.trim().length > 0)
312
+ return true;
313
+ const deps = item.dependencies;
314
+ if (Array.isArray(deps)) {
315
+ for (const d of deps) {
316
+ if (d && typeof d.kind === "string" && d.kind.trim().toLowerCase() === "blocked_by") {
317
+ return true;
318
+ }
319
+ }
320
+ }
321
+ return false;
322
+ }
323
+ /**
324
+ * Local-day key (YYYY-MM-DD in the host's local timezone) for an item's last
325
+ * activity. Used by the `--yesterday` split. Falls back to created_at, then
326
+ * to the empty string when no timestamp is parseable.
327
+ */
328
+ export function localDayKey(item) {
329
+ const ts = Date.parse(item.updated_at ?? item.created_at ?? "");
330
+ if (isNaN(ts))
331
+ return "";
332
+ const d = new Date(ts);
333
+ const y = d.getFullYear();
334
+ const m = String(d.getMonth() + 1).padStart(2, "0");
335
+ const day = String(d.getDate()).padStart(2, "0");
336
+ return `${y}-${m}-${day}`;
337
+ }
338
+ /** Local-day key (YYYY-MM-DD) for a given epoch-ms instant. */
339
+ export function localDayKeyOf(ms) {
340
+ const d = new Date(ms);
341
+ const y = d.getFullYear();
342
+ const m = String(d.getMonth() + 1).padStart(2, "0");
343
+ const day = String(d.getDate()).padStart(2, "0");
344
+ return `${y}-${m}-${day}`;
345
+ }
116
346
  /**
117
347
  * Bucket items into standup sections.
118
- * `since` (ISO date/time) filters the Done section to items updated within the
119
- * window; WIP/blocked/up-next always reflect current state.
348
+ * `sinceMs` (epoch ms, NaN = no window) filters the Done section to items
349
+ * updated within the window; WIP/blocked/up-next always reflect current state.
120
350
  */
121
- function buildStandupData(items, opts) {
122
- const sinceMs = opts.since ? Date.parse(opts.since) : NaN;
123
- const withinWindow = (item) => {
124
- if (isNaN(sinceMs))
125
- return true;
126
- const ts = Date.parse(item.updated_at ?? item.created_at ?? "");
127
- return isNaN(ts) ? false : ts >= sinceMs;
128
- };
129
- const wip = items.filter((i) => WIP_STATUSES.has(statusOf(i)));
130
- const blocked = items.filter((i) => BLOCKED_STATUSES.has(statusOf(i)));
131
- const open = items.filter((i) => OPEN_STATUSES.has(statusOf(i)));
351
+ export function buildStandupData(items, opts, sinceMs = NaN, now = Date.now()) {
352
+ const isDone = (i) => DONE_STATUSES.has(statusOf(i));
353
+ // An item is "blocked" for standup purposes when its status is blocked/
354
+ // on_hold OR it carries a blocked_by dependency β€” but a done item is never
355
+ // re-surfaced as blocked (a closed impediment is no longer an impediment).
356
+ const isBlocked = (i) => !isDone(i) && (BLOCKED_STATUSES.has(statusOf(i)) || hasBlockedByDep(i));
357
+ const wip = items.filter((i) => WIP_STATUSES.has(statusOf(i)) && !isBlocked(i));
358
+ const blocked = items.filter(isBlocked);
359
+ const open = items.filter((i) => OPEN_STATUSES.has(statusOf(i)) && !isBlocked(i));
132
360
  const done = opts.includeDone
133
- ? items.filter((i) => DONE_STATUSES.has(statusOf(i)) && withinWindow(i))
361
+ ? items.filter((i) => isDone(i) && withinWindow(i, sinceMs))
134
362
  : [];
135
363
  const upNext = [...open]
136
364
  .sort((a, b) => (a.priority ?? 9999) - (b.priority ?? 9999))
137
365
  .slice(0, 3);
138
- return { wip, blocked, done, upNext, total: items.length };
366
+ const data = { wip, blocked, done, upNext, total: items.length };
367
+ if (opts.splitYesterday && done.length > 0) {
368
+ const todayKey = localDayKeyOf(now);
369
+ const yesterdayKey = localDayKeyOf(now - 86_400_000);
370
+ data.doneToday = done.filter((i) => localDayKey(i) === todayKey);
371
+ data.doneYesterday = done.filter((i) => localDayKey(i) === yesterdayKey);
372
+ }
373
+ return data;
374
+ }
375
+ const SECTION_META = {
376
+ in_progress: { emoji: "πŸƒ", title: "In Progress", emptyNote: "nothing in progress", withPriority: false },
377
+ blocked: { emoji: "🚫", title: "Blocked", emptyNote: "nothing blocked", withPriority: false },
378
+ done: { emoji: "βœ…", title: "Done", emptyNote: null, withPriority: false },
379
+ up_next: { emoji: "πŸ“‹", title: "Up Next", emptyNote: null, withPriority: true },
380
+ };
381
+ /**
382
+ * Apply any `--section-labels` override for `key` to the given emoji/title.
383
+ */
384
+ function labeled(key, emoji, title, opts) {
385
+ const ov = opts.sectionLabels[key];
386
+ if (!ov)
387
+ return { emoji, title };
388
+ return { emoji: ov.emoji ?? emoji, title: ov.title ?? title };
389
+ }
390
+ /**
391
+ * Resolve the ordered, selected section definitions for the given data.
392
+ * `in_progress` and `blocked` always render (even empty, with their note);
393
+ * `done` and `up_next` only render when they hold items β€” preserving the
394
+ * historical message shape. `--sections` filters which keys are eligible.
395
+ *
396
+ * When `--yesterday` is active and Done has items, the single Done section is
397
+ * expanded into "Done Yesterday" + "Done Today" (only the non-empty subsets
398
+ * render), preserving the section's position in the ordering.
399
+ */
400
+ export function resolveSections(data, opts) {
401
+ const itemsFor = {
402
+ in_progress: data.wip,
403
+ blocked: data.blocked,
404
+ done: data.done,
405
+ up_next: data.upNext,
406
+ };
407
+ const alwaysShow = {
408
+ in_progress: true,
409
+ blocked: true,
410
+ done: false,
411
+ up_next: false,
412
+ };
413
+ const out = [];
414
+ for (const key of opts.sections) {
415
+ const items = itemsFor[key];
416
+ const meta = SECTION_META[key];
417
+ if (key === "done" && opts.splitYesterday && data.doneYesterday && data.doneToday) {
418
+ const subsets = [
419
+ ["Done Yesterday", data.doneYesterday],
420
+ ["Done Today", data.doneToday],
421
+ ];
422
+ // The day distinction owns the title here, so a --section-labels
423
+ // override for `done` contributes only its emoji (not its title).
424
+ const doneEmoji = opts.sectionLabels.done?.emoji ?? meta.emoji;
425
+ for (const [subTitle, subItems] of subsets) {
426
+ if (subItems.length === 0)
427
+ continue;
428
+ out.push({ key, emoji: doneEmoji, title: subTitle, items: subItems, emptyNote: meta.emptyNote, withPriority: meta.withPriority });
429
+ }
430
+ continue;
431
+ }
432
+ if (!alwaysShow[key] && items.length === 0)
433
+ continue;
434
+ const { emoji, title } = labeled(key, meta.emoji, meta.title, opts);
435
+ out.push({ key, emoji, title, items, emptyNote: meta.emptyNote, withPriority: meta.withPriority });
436
+ }
437
+ return out;
139
438
  }
140
439
  // ---------------------------------------------------------------------------
141
440
  // Rendering helpers
@@ -147,12 +446,12 @@ function typeLabel(item) {
147
446
  return `[${label}]`;
148
447
  }
149
448
  function mentionFor(item, mentionMap) {
150
- const author = item.assignee;
449
+ const author = item.assignee ?? item.author;
151
450
  if (author && mentionMap[author])
152
451
  return ` (${mentionMap[author]})`;
153
452
  return "";
154
453
  }
155
- function itemText(item, mentionMap, withPriority = false) {
454
+ export function itemText(item, mentionMap, withPriority = false) {
156
455
  const label = typeLabel(item);
157
456
  const title = label ? `${label} ${item.title}` : item.title;
158
457
  const prio = withPriority && item.priority != null ? ` (priority ${item.priority})` : "";
@@ -162,68 +461,122 @@ function todayISO() {
162
461
  return new Date().toISOString().slice(0, 10);
163
462
  }
164
463
  /**
165
- * Group a list of items by assignee. Items with no assignee bucket under
166
- * "_unassigned". Returns entries sorted by assignee name for stable output.
464
+ * Group a list of items by the configured field (assignee, sprint or type).
465
+ * Items missing the field bucket under a synthetic "_none" key (rendered as a
466
+ * friendly label). Returns entries sorted by group key for stable output.
167
467
  */
168
- function groupByAssignee(items) {
468
+ export function groupItems(items, groupBy) {
169
469
  const groups = new Map();
170
470
  for (const item of items) {
171
- const key = item.assignee ?? "_unassigned";
172
- (groups.get(key) ?? groups.set(key, []).get(key)).push(item);
471
+ let key;
472
+ if (groupBy === "assignee")
473
+ key = item.assignee ?? "_none";
474
+ else if (groupBy === "sprint")
475
+ key = item.sprint ?? "_none";
476
+ else if (groupBy === "type")
477
+ key = item.type ?? "_none";
478
+ else
479
+ key = "_none";
480
+ const bucket = groups.get(key);
481
+ if (bucket)
482
+ bucket.push(item);
483
+ else
484
+ groups.set(key, [item]);
173
485
  }
174
486
  return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
175
487
  }
488
+ function groupLabel(key, groupBy) {
489
+ if (key !== "_none")
490
+ return key;
491
+ if (groupBy === "assignee")
492
+ return "Unassigned";
493
+ if (groupBy === "sprint")
494
+ return "No sprint";
495
+ if (groupBy === "type")
496
+ return "Untyped";
497
+ return key;
498
+ }
499
+ const isGrouped = (opts) => opts.groupBy !== "status";
176
500
  // ---------------------------------------------------------------------------
177
- // Plain-text / mrkdwn message (fallback + dry-run preview)
501
+ // Plain-text / mrkdwn / markdown message (fallback + dry-run preview)
178
502
  // ---------------------------------------------------------------------------
179
- function renderSection(lines, emoji, title, items, emptyNote, opts, withPriority = false) {
180
- const heading = opts.format === "slack" ? `${emoji} *${title}* (${items.length})` : `${emoji} ${title} (${items.length})`;
181
- lines.push(heading);
182
- if (items.length === 0) {
183
- if (emptyNote)
184
- lines.push(opts.format === "slack" ? `β€’ _${emptyNote}_` : `β€’ ${emptyNote}`);
503
+ function bold(text, format) {
504
+ if (format === "slack")
505
+ return `*${text}*`;
506
+ if (format === "markdown")
507
+ return `**${text}**`;
508
+ return text;
509
+ }
510
+ function italic(text, format) {
511
+ if (format === "slack")
512
+ return `_${text}_`;
513
+ if (format === "markdown")
514
+ return `_${text}_`;
515
+ return text;
516
+ }
517
+ function renderSection(lines, def, opts) {
518
+ const count = `(${def.items.length})`;
519
+ if (opts.format === "markdown") {
520
+ lines.push(`## ${def.emoji} ${def.title} ${count}`);
521
+ }
522
+ else {
523
+ lines.push(`${def.emoji} ${bold(def.title, opts.format)} ${count}`);
524
+ }
525
+ if (def.items.length === 0) {
526
+ if (def.emptyNote) {
527
+ const bullet = opts.format === "markdown" ? "- " : "β€’ ";
528
+ lines.push(`${bullet}${italic(def.emptyNote, opts.format)}`);
529
+ }
185
530
  return;
186
531
  }
187
- if (opts.groupBy === "assignee") {
188
- for (const [assignee, group] of groupByAssignee(items)) {
189
- const name = assignee === "_unassigned" ? "Unassigned" : assignee;
190
- lines.push(opts.format === "slack" ? ` *${name}*` : ` ${name}`);
191
- for (const item of group)
192
- lines.push(` β€’ ${itemText(item, opts.mentionMap, withPriority)}`);
532
+ if (isGrouped(opts)) {
533
+ for (const [key, group] of groupItems(def.items, opts.groupBy)) {
534
+ const name = groupLabel(key, opts.groupBy);
535
+ if (opts.format === "markdown")
536
+ lines.push(`- ${bold(name, opts.format)}`);
537
+ else
538
+ lines.push(` ${bold(name, opts.format)}`);
539
+ for (const item of group) {
540
+ const bullet = opts.format === "markdown" ? " - " : " β€’ ";
541
+ lines.push(`${bullet}${itemText(item, opts.mentionMap, def.withPriority)}`);
542
+ }
193
543
  }
194
544
  }
195
545
  else {
196
- for (const item of items)
197
- lines.push(`β€’ ${itemText(item, opts.mentionMap, withPriority)}`);
546
+ const bullet = opts.format === "markdown" ? "- " : "β€’ ";
547
+ for (const item of def.items)
548
+ lines.push(`${bullet}${itemText(item, opts.mentionMap, def.withPriority)}`);
198
549
  }
199
550
  }
200
- function buildTextMessage(data, opts) {
551
+ /**
552
+ * Render the standup as a single text blob for the chosen non-Block-Kit
553
+ * format. `slack` is byte-identical to the historical output (mrkdwn);
554
+ * `plain` drops emphasis punctuation; `markdown` uses `#`/`**`/`-`.
555
+ */
556
+ export function buildTextMessage(data, opts) {
201
557
  const lines = [];
202
558
  const dateStr = todayISO();
203
- if (opts.channel)
204
- lines.push(`> Channel: ${opts.channel}`);
205
- lines.push(opts.format === "slack" ? `πŸ“Š *pm standup* β€” ${dateStr}` : `πŸ“Š pm standup β€” ${dateStr}`);
206
- lines.push("");
207
- renderSection(lines, "πŸƒ", "In Progress", data.wip, "nothing in progress", opts);
208
- lines.push("");
209
- renderSection(lines, "🚫", "Blocked", data.blocked, "nothing blocked", opts);
210
- if (data.done.length > 0) {
211
- lines.push("");
212
- renderSection(lines, "βœ…", "Done", data.done, null, opts);
213
- }
214
- if (data.upNext.length > 0) {
215
- lines.push("");
216
- renderSection(lines, "πŸ“‹", "Up Next", data.upNext, null, opts, true);
559
+ if (opts.channel) {
560
+ lines.push(opts.format === "markdown" ? `> Channel: ${opts.channel}` : `> Channel: ${opts.channel}`);
217
561
  }
562
+ const title = `πŸ“Š ${bold("pm standup", opts.format)} β€” ${dateStr}`;
563
+ lines.push(opts.format === "markdown" ? `# πŸ“Š pm standup β€” ${dateStr}` : title);
564
+ lines.push("");
565
+ const sections = resolveSections(data, opts);
566
+ sections.forEach((def, idx) => {
567
+ if (idx > 0)
568
+ lines.push("");
569
+ renderSection(lines, def, opts);
570
+ });
218
571
  return lines.join("\n");
219
572
  }
220
573
  function mrkdwnList(items, opts, withPriority = false) {
221
574
  if (items.length === 0)
222
575
  return "_none_";
223
- if (opts.groupBy === "assignee") {
576
+ if (isGrouped(opts)) {
224
577
  const parts = [];
225
- for (const [assignee, group] of groupByAssignee(items)) {
226
- const name = assignee === "_unassigned" ? "Unassigned" : assignee;
578
+ for (const [key, group] of groupItems(items, opts.groupBy)) {
579
+ const name = groupLabel(key, opts.groupBy);
227
580
  parts.push(`*${name}*`);
228
581
  for (const item of group)
229
582
  parts.push(`β€’ ${itemText(item, opts.mentionMap, withPriority)}`);
@@ -233,17 +586,23 @@ function mrkdwnList(items, opts, withPriority = false) {
233
586
  return items.map((item) => `β€’ ${itemText(item, opts.mentionMap, withPriority)}`).join("\n");
234
587
  }
235
588
  /**
236
- * Build a Slack Block Kit `blocks` array: a header, a section per standup
237
- * bucket (In Progress / Blocked / Up Next / optional Done) and a context
238
- * footer. Returns the blocks plus a plain-text `fallback` Slack renders in
239
- * notifications and old clients.
589
+ * Build a Slack Block Kit `blocks` array: a header, a section per selected
590
+ * standup bucket and a context footer. Returns the blocks plus a plain-text
591
+ * `fallback` Slack renders in notifications and old clients.
592
+ *
593
+ * Block Kit schema choices: a single `header` block (plain_text, capped at
594
+ * Slack's 150-char limit), one `section`/`mrkdwn` block per bucket (Slack
595
+ * caps section text at 3000 chars β€” long buckets are truncated with an
596
+ * ellipsis to stay valid), a `divider`, then a `context` footer summarizing
597
+ * counts / window / grouping.
240
598
  */
241
- function buildBlockKit(data, opts) {
599
+ export function buildBlockKit(data, opts) {
242
600
  const blocks = [];
243
601
  const dateStr = todayISO();
602
+ const truncate = (text, max) => text.length <= max ? text : text.slice(0, max - 1) + "…";
244
603
  blocks.push({
245
604
  type: "header",
246
- text: { type: "plain_text", text: `πŸ“Š pm standup β€” ${dateStr}`, emoji: true },
605
+ text: { type: "plain_text", text: truncate(`πŸ“Š pm standup β€” ${dateStr}`, 150), emoji: true },
247
606
  });
248
607
  if (opts.channel) {
249
608
  blocks.push({
@@ -251,39 +610,52 @@ function buildBlockKit(data, opts) {
251
610
  elements: [{ type: "mrkdwn", text: `Channel: ${opts.channel}` }],
252
611
  });
253
612
  }
254
- const section = (emoji, title, items, withPriority = false) => {
613
+ for (const def of resolveSections(data, opts)) {
255
614
  blocks.push({
256
615
  type: "section",
257
616
  text: {
258
617
  type: "mrkdwn",
259
- text: `${emoji} *${title}* (${items.length})\n${mrkdwnList(items, opts, withPriority)}`,
618
+ text: truncate(`${def.emoji} *${def.title}* (${def.items.length})\n${mrkdwnList(def.items, opts, def.withPriority)}`, 3000),
260
619
  },
261
620
  });
262
- };
263
- section("πŸƒ", "In Progress", data.wip);
264
- section("🚫", "Blocked", data.blocked);
265
- section("πŸ“‹", "Up Next", data.upNext, true);
266
- if (data.done.length > 0)
267
- section("βœ…", "Done", data.done);
621
+ }
268
622
  blocks.push({ type: "divider" });
623
+ const groupNote = {
624
+ status: null,
625
+ assignee: "grouped by assignee",
626
+ sprint: "grouped by sprint",
627
+ type: "grouped by type",
628
+ };
269
629
  const footerBits = [
270
630
  `${data.total} item(s) total`,
271
631
  opts.since ? `since ${opts.since}` : null,
272
- opts.groupBy === "assignee" ? "grouped by assignee" : null,
632
+ groupNote[opts.groupBy],
273
633
  ].filter(Boolean);
274
634
  blocks.push({
275
635
  type: "context",
276
636
  elements: [{ type: "mrkdwn", text: `πŸ€– pm-slack-standup Β· ${footerBits.join(" Β· ")}` }],
277
637
  });
278
- // Plain-text fallback mirrors the text message (slack mrkdwn variant).
638
+ // Plain-text fallback mirrors the slack-mrkdwn text message.
279
639
  const fallback = buildTextMessage(data, { ...opts, format: "slack" });
280
640
  return { blocks, fallback };
281
641
  }
642
+ /**
643
+ * Render the standup in whichever `--format` was selected, as the string the
644
+ * command prints (dry-run) or the exporter writes. `blockkit` returns the
645
+ * pretty-printed `{ blocks }` JSON; everything else returns text.
646
+ */
647
+ export function renderStandup(data, opts) {
648
+ if (opts.format === "blockkit") {
649
+ const { blocks } = buildBlockKit(data, opts);
650
+ return JSON.stringify({ blocks }, null, 2);
651
+ }
652
+ return buildTextMessage(data, opts);
653
+ }
282
654
  // ---------------------------------------------------------------------------
283
655
  // Slack transport
284
656
  // ---------------------------------------------------------------------------
285
657
  function postToSlack(webhookUrl, payload) {
286
- return new Promise((resolve, reject) => {
658
+ return new Promise((resolvePromise, reject) => {
287
659
  const body = JSON.stringify(payload);
288
660
  const url = new URL(webhookUrl);
289
661
  const options = {
@@ -300,7 +672,7 @@ function postToSlack(webhookUrl, payload) {
300
672
  res.on("data", (chunk) => (respBody += chunk.toString()));
301
673
  res.on("end", () => {
302
674
  if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
303
- resolve();
675
+ resolvePromise();
304
676
  }
305
677
  else {
306
678
  reject(new Error(`Slack webhook returned HTTP ${res.statusCode ?? "unknown"}: ${respBody}`));
@@ -313,27 +685,92 @@ function postToSlack(webhookUrl, payload) {
313
685
  req.end();
314
686
  });
315
687
  }
688
+ /**
689
+ * Resolve the ordered list of post targets from `--webhook`/env + `--channel`
690
+ * + `--channels`. Each `--channels` token is either a `#name` (posted to the
691
+ * base webhook, just changing the displayed channel) or a full webhook URL
692
+ * (posted to that URL). When no `--channels` is given, a single target using
693
+ * the base webhook + `--channel` is returned. De-dupes (webhook,channel) pairs.
694
+ */
695
+ export function resolvePostTargets(baseWebhook, baseChannel, channels) {
696
+ if (channels.length === 0) {
697
+ return [{ webhookUrl: baseWebhook, channel: baseChannel }];
698
+ }
699
+ const out = [];
700
+ const seen = new Set();
701
+ for (const token of channels) {
702
+ const target = isWebhookUrl(token)
703
+ ? { webhookUrl: token, channel: baseChannel }
704
+ : { webhookUrl: baseWebhook, channel: token };
705
+ const dedupeKey = `${target.webhookUrl}${target.channel ?? ""}`;
706
+ if (seen.has(dedupeKey))
707
+ continue;
708
+ seen.add(dedupeKey);
709
+ out.push(target);
710
+ }
711
+ return out;
712
+ }
713
+ /**
714
+ * Post the standup to every resolved target, re-rendering per target so each
715
+ * channel's message shows its own channel name. Returns a per-target result;
716
+ * never throws β€” the caller decides how to treat failures (e.g. fallback to
717
+ * stdout). The `poster` is injectable so this is testable without a network.
718
+ */
719
+ export async function postStandupTargets(targets, data, baseOpts, poster) {
720
+ const results = [];
721
+ for (const target of targets) {
722
+ const opts = { ...baseOpts, channel: target.channel };
723
+ const { blocks, fallback } = buildBlockKit(data, opts);
724
+ try {
725
+ await poster(target.webhookUrl, { text: fallback, blocks, mrkdwn: true });
726
+ results.push({ channel: target.channel, ok: true });
727
+ }
728
+ catch (err) {
729
+ results.push({
730
+ channel: target.channel,
731
+ ok: false,
732
+ error: err instanceof Error ? err.message : String(err),
733
+ });
734
+ }
735
+ }
736
+ return results;
737
+ }
316
738
  // ---------------------------------------------------------------------------
317
739
  // Shared option resolution
318
740
  // ---------------------------------------------------------------------------
319
- function resolveStandupOptions(options) {
320
- const rawFormat = readStrOption(options, "format");
321
- const rawGroup = readStrOption(options, "group-by");
322
- return {
741
+ /**
742
+ * Resolve every standup option except the render `format`, which differs
743
+ * between the command (slack|blockkit|markdown|plain) and the exporter
744
+ * (md|json file format). Callers supply the format they want.
745
+ */
746
+ export function resolveStandupOptions(options, format) {
747
+ const since = readStrOption(options, "since");
748
+ const days = parseDays(readStrOption(options, "days"));
749
+ const splitYesterday = readBoolOption(options, "yesterday");
750
+ const opts = {
323
751
  channel: readStrOption(options, "channel"),
324
- format: rawFormat === "text" ? "text" : "slack",
325
- includeDone: readBoolOption(options, "include-done"),
326
- since: readStrOption(options, "since"),
327
- groupBy: rawGroup === "assignee" ? "assignee" : "status",
752
+ format,
753
+ // `--yesterday` is meaningless without a Done section, so it implies
754
+ // `--include-done` (additive: passing only `--include-done` is unchanged).
755
+ includeDone: readBoolOption(options, "include-done") || splitYesterday,
756
+ since,
757
+ groupBy: parseGroupBy(readStrOption(options, "group-by")),
758
+ sections: parseSections(readStrOption(options, "sections")),
328
759
  mentionMap: parseMentionMap(readStrOption(options, "mention-map")),
760
+ splitYesterday,
761
+ sectionLabels: parseSectionLabels(readStrOption(options, "section-labels")),
329
762
  };
763
+ // `--days` implies windowing the Done section; surface it even without
764
+ // `--include-done` being set so the footer/window stays accurate.
765
+ const sinceMs = resolveSinceMs(since, days);
766
+ return { opts, sinceMs };
330
767
  }
331
768
  // ---------------------------------------------------------------------------
332
769
  // Extension
333
770
  // ---------------------------------------------------------------------------
334
771
  export default defineExtension({
335
772
  name: "pm-slack-standup",
336
- version: "2026.6.2",
773
+ version: "2026.6.4",
337
774
  activate(api) {
338
775
  api.registerCommand({
339
776
  name: "standup",
@@ -342,43 +779,48 @@ export default defineExtension({
342
779
  examples: [
343
780
  "pm standup --webhook https://hooks.slack.com/services/...",
344
781
  "pm standup --channel '#team-eng' --dry-run",
345
- "pm standup --include-done --since 2026-06-01",
782
+ "pm standup --dry-run --format blockkit",
783
+ "pm standup --dry-run --format markdown --include-done --days 7",
346
784
  "pm standup --group-by assignee --mention-map 'alice=@alice.s,bob=@bob'",
785
+ "pm standup --group-by sprint --sections in_progress,blocked",
786
+ "pm standup --dry-run --yesterday --format plain",
787
+ "pm standup --channels '#team-eng,#standups' --dry-run",
788
+ "pm standup --section-labels 'in_progress=Rolling,blocked=πŸ”₯ On Fire' --dry-run",
347
789
  "PM_SLACK_WEBHOOK=https://... pm standup --channel '#standups'",
348
790
  ],
349
791
  flags: [
350
792
  { long: "--webhook", value_name: "url", description: "Slack incoming webhook URL (overrides PM_SLACK_WEBHOOK env var)" },
351
793
  { long: "--channel", value_name: "name", description: "Channel name shown in the message (e.g. #team-eng)" },
352
- { long: "--dry-run", description: "Print the message without posting to Slack" },
794
+ { long: "--dry-run", description: "Build and print the message in the chosen format WITHOUT posting to Slack" },
795
+ { long: "--format", value_name: "fmt", description: "Output format: slack (mrkdwn, default) | blockkit (JSON) | markdown | plain" },
353
796
  { long: "--include-done", description: "Include recently-closed items in a Done section" },
354
- { long: "--since", value_name: "iso", description: "ISO date/time window; filters the Done section to items updated since then" },
355
- { long: "--group-by", value_name: "field", description: "Group section items by 'status' (default) or 'assignee'" },
797
+ { long: "--since", value_name: "iso", description: "ISO date/time window; scopes the Done section to items updated since then" },
798
+ { long: "--days", value_name: "n", description: "Relative window: scope Done to items updated in the last N days" },
799
+ { long: "--group-by", value_name: "field", description: "Group section items by status (default) | assignee | sprint | type" },
800
+ { long: "--sections", value_name: "list", description: "Comma list of sections to render: in_progress,blocked,done,up_next" },
356
801
  { long: "--mention-map", value_name: "map", description: "Map pm authors to Slack handles, e.g. 'alice=@alice,bob=@bob'" },
357
- { long: "--format", value_name: "fmt", description: "Plain-text rendering: 'slack' uses mrkdwn, 'text' is plain (default: slack)" },
802
+ { long: "--yesterday", description: "Split the Done section into 'Done Yesterday' / 'Done Today' by local day (implies --include-done)" },
803
+ { long: "--channels", value_name: "list", description: "Post the same standup to multiple targets: comma list of #channel names and/or webhook URLs" },
804
+ { long: "--fallback-to-stdout", description: "If the Slack post fails, print the rendered standup to stdout instead of exiting non-zero" },
805
+ { long: "--section-labels", value_name: "map", description: "Override section titles/emoji, e.g. 'in_progress=Rolling,blocked=πŸ”₯ On Fire'" },
358
806
  ],
359
807
  async run(ctx) {
360
808
  const webhookUrl = readStrOption(ctx.options, "webhook") ?? process.env["PM_SLACK_WEBHOOK"] ?? "";
361
809
  const dryRun = readBoolOption(ctx.options, "dry-run");
362
- const opts = resolveStandupOptions(ctx.options);
363
- if (!dryRun && !webhookUrl) {
364
- // Graceful no-op when no webhook is configured: warn and exit 0 so the
365
- // command never blocks a workflow on a missing/unset webhook.
366
- console.error("PM_SLACK_WEBHOOK not set and no --webhook provided β€” Slack posting disabled. " +
367
- "Use --dry-run to preview the message.");
368
- return { posted: false, disabled: true, reason: "no-webhook" };
369
- }
810
+ const { opts, sinceMs } = resolveStandupOptions(ctx.options, parseFormat(readStrOption(ctx.options, "format")));
370
811
  const items = fetchAllItems(ctx.pm_root);
371
- const data = buildStandupData(items, opts);
372
- const { blocks, fallback } = buildBlockKit(data, opts);
373
- const textPreview = opts.format === "text" ? buildTextMessage(data, opts) : fallback;
812
+ const data = buildStandupData(items, opts, sinceMs);
374
813
  if (dryRun) {
375
- console.error("--- DRY RUN (message not posted) ---");
376
- process.stdout.write(textPreview + "\n");
377
- console.error("--- Block Kit payload ---");
378
- process.stdout.write(JSON.stringify({ blocks }, null, 2) + "\n");
814
+ // No network call happens on this path.
815
+ const rendered = renderStandup(data, opts);
816
+ console.error(`--- DRY RUN (${opts.format}, message not posted) ---`);
817
+ process.stdout.write(rendered + "\n");
379
818
  console.error("--- END ---");
819
+ const { blocks, fallback } = buildBlockKit(data, opts);
380
820
  return {
381
821
  dryRun: true,
822
+ format: opts.format,
823
+ rendered,
382
824
  blocks,
383
825
  fallback,
384
826
  wip: data.wip.length,
@@ -387,17 +829,46 @@ export default defineExtension({
387
829
  upNext: data.upNext.length,
388
830
  };
389
831
  }
390
- try {
391
- await postToSlack(webhookUrl, { text: fallback, blocks, mrkdwn: true });
832
+ const channels = parseChannels(readStrOption(ctx.options, "channels"));
833
+ const fallbackToStdout = readBoolOption(ctx.options, "fallback-to-stdout");
834
+ // Real post path: a missing webhook is a hard, structured error (exit 1)
835
+ // rather than a crash or silent success. `--channels` entries that are
836
+ // bare #names still need a base webhook to post to. Use --dry-run to
837
+ // preview, or --fallback-to-stdout to print instead of erroring.
838
+ const needsBaseWebhook = channels.length === 0 || channels.some((c) => !isWebhookUrl(c));
839
+ if (!webhookUrl && needsBaseWebhook) {
840
+ throw new CommandError("No Slack webhook configured. Set PM_SLACK_WEBHOOK or pass --webhook <url>, " +
841
+ "or use --dry-run to preview the message without posting.", EXIT_CODE.GENERIC_FAILURE);
392
842
  }
393
- catch (err) {
394
- // Never throw on network failure: warn and exit 0 so a flaky Slack
395
- // endpoint doesn't break the caller's workflow.
396
- console.error(`Slack post failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
397
- return { posted: false, error: err instanceof Error ? err.message : String(err) };
843
+ const targets = resolvePostTargets(webhookUrl, opts.channel, channels);
844
+ const results = await postStandupTargets(targets, data, opts, postToSlack);
845
+ const failures = results.filter((r) => !r.ok);
846
+ if (failures.length > 0 && fallbackToStdout) {
847
+ // Print the rendered standup so the work isn't lost on a transport
848
+ // failure. We exit 0 here: stdout delivery is the requested fallback.
849
+ for (const f of failures) {
850
+ console.error(`Slack post to ${f.channel ?? "(default channel)"} failed: ${f.error ?? "unknown error"} β€” falling back to stdout.`);
851
+ }
852
+ const rendered = renderStandup(data, opts);
853
+ process.stdout.write(rendered + "\n");
854
+ return {
855
+ posted: results.some((r) => r.ok),
856
+ fallbackToStdout: true,
857
+ results,
858
+ wip: data.wip.length,
859
+ blocked: data.blocked.length,
860
+ done: data.done.length,
861
+ upNext: data.upNext.length,
862
+ };
863
+ }
864
+ if (failures.length > 0) {
865
+ throw new CommandError(`Slack post failed for ${failures.length} of ${targets.length} target(s): ` +
866
+ failures.map((f) => `${f.channel ?? "(default)"}: ${f.error ?? "unknown"}`).join("; "), EXIT_CODE.GENERIC_FAILURE);
398
867
  }
399
868
  return {
400
869
  posted: true,
870
+ channel: opts.channel,
871
+ channels: targets.length > 1 ? targets.map((t) => t.channel) : undefined,
401
872
  wip: data.wip.length,
402
873
  blocked: data.blocked.length,
403
874
  done: data.done.length,
@@ -412,14 +883,16 @@ export default defineExtension({
412
883
  // (No collision with the `pm standup` command β€” different invocation.)
413
884
  // -----------------------------------------------------------------------
414
885
  api.registerExporter("standup", async (ctx) => {
415
- const opts = resolveStandupOptions(ctx.options);
416
886
  const rawFormat = (readStrOption(ctx.options, "format") ?? "md").toLowerCase();
417
- // For the exporter, --format selects the file format (md|json); the
418
- // mrkdwn-vs-plain text choice is irrelevant here so default text to plain.
887
+ // For the exporter, --format selects the file format (md|json); the text
888
+ // rendering is always markdown. We resolve options with markdown rather
889
+ // than routing the exporter's md|json through the command's --format
890
+ // validator (which only knows slack|blockkit|markdown|plain).
419
891
  const fileFormat = rawFormat === "json" ? "json" : "md";
420
- const exportOpts = { ...opts, format: "text" };
892
+ const { opts, sinceMs } = resolveStandupOptions(ctx.options, "markdown");
893
+ const exportOpts = opts;
421
894
  const items = fetchAllItems(ctx.pm_root);
422
- const data = buildStandupData(items, exportOpts);
895
+ const data = buildStandupData(items, exportOpts, sinceMs);
423
896
  let output;
424
897
  if (fileFormat === "json") {
425
898
  const { blocks, fallback } = buildBlockKit(data, opts);
@@ -428,6 +901,7 @@ export default defineExtension({
428
901
  channel: opts.channel,
429
902
  since: opts.since,
430
903
  groupBy: opts.groupBy,
904
+ sections: opts.sections,
431
905
  counts: {
432
906
  wip: data.wip.length,
433
907
  blocked: data.blocked.length,
@@ -435,7 +909,7 @@ export default defineExtension({
435
909
  upNext: data.upNext.length,
436
910
  total: data.total,
437
911
  },
438
- sections: {
912
+ sections_data: {
439
913
  in_progress: data.wip,
440
914
  blocked: data.blocked,
441
915
  up_next: data.upNext,
@@ -445,11 +919,7 @@ export default defineExtension({
445
919
  }, null, 2);
446
920
  }
447
921
  else {
448
- // Markdown: reuse the plain-text renderer, upgrade headings to `##`.
449
- const md = buildTextMessage(data, exportOpts)
450
- .replace(/^πŸ“Š pm standup β€” (.+)$/m, "# pm standup β€” $1")
451
- .replace(/^(πŸƒ|🚫|βœ…|πŸ“‹) (.+)$/gm, "## $1 $2");
452
- output = md;
922
+ output = buildTextMessage(data, exportOpts);
453
923
  }
454
924
  const outputPath = readStrOption(ctx.options, "output");
455
925
  if (outputPath) {