npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@smi0001/agent-scrum-master

v1.0.0

Published

Summarize each teammate's daily Jira activity and post it to Slack/email as the daily standup.

Readme

agent-scrum-master

Pulls each teammate's Jira activity for a given day (issues created, updated, or commented on) directly from Jira, groups it by person, and previews or posts it as a daily standup — to Slack, email, or both. Replaces filling a Google Sheet by hand and posting it to Slack manually.

Install

git clone <this repo>
cd agent-scrum-master
npm install
cp .env.example .env   # fill in the values below
npm run build

Run from source during development with npm start -- run [flags], or after building, node dist/index.js run [flags] (or agent-scrum-master run [flags] once linked/installed).

Environment variables

| Variable | Required when | Notes | |---|---|---| | JIRA_BASE_URL | always | e.g. https://yourcompany.atlassian.net | | JIRA_EMAIL | always | the account owning JIRA_API_TOKEN | | JIRA_API_TOKEN | always | create one at https://id.atlassian.com/manage-profile/security/api-tokens | | JIRA_TEAM_USERS | always | comma-separated emails/usernames of the roster to report on | | JIRA_PROJECTS | always | comma-separated Jira project keys to scan — each is verified to exist and be accessible before the run proceeds (a typo throws Invalid Jira project key(s): ... rather than silently reporting nothing for it) | | JIRA_CONCURRENCY | optional | parallel changelog/comment fetches per batch (default 5) | | JIRA_SUBSTATUS_FIELD_ID | optional | custom field ID for your instance's "Sub Status" field (default customfield_10736) — custom field IDs aren't portable across Jira sites, check yours via GET /rest/api/3/field | | COMMENT_SNIPPET_LENGTH | optional | characters of a same-day comment to show per issue line (default 50) | | EXCLUDE_CHANGELOG_FIELDS | optional | comma-separated changelog field names (case-insensitive, e.g. assignee, Rank, Flagged) that never count as "updated" activity on their own (default assignee) — set to "" (empty, not unset) to exclude nothing. A history entry is only dropped if every field it touched is excluded | | ISSUE_DISPLAY_LIMIT | optional | issues to show per teammate per project before truncating with a "+ N more" Jira link (default 5) | | REPORT_TIMEZONE | optional | timezone for resolving "today" when --date isn't passed (default Asia/Kolkata) — match your Jira instance's configured timezone | | STANDUP_WINDOW_HOURS | optional | hours back from now for a normal-day cron run (default 24) — read by scripts/daily-standup-rolling.sh only, not the CLI itself or the whole-day scripts | | STANDUP_WEEKEND_WINDOW_HOURS | optional | hours back from now for scripts/daily-standup-rolling.sh on Monday, to also catch up the weekend gap (default 72) | | DELIVERY_CHANNELS | always (or pass --channel-type) | comma-separated, any of slack, email, sheet | | SLACK_BOT_TOKEN + SLACK_CHANNEL | slack active, unless SLACK_WEBHOOK_URL is set | bot token with the chat:write scope; channel ID the bot is invited to | | SLACK_WEBHOOK_URL | slack active, unless the bot token pair is set | incoming webhook URL — simpler, but its destination channel is fixed at creation, so --channel has no effect through it | | SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS | email active | SMTP relay credentials | | EMAIL_FROM | email active | sender address | | EMAIL_TO | email active, unless --to is passed | comma-separated recipient list | | GOOGLE_SHEET_URL | sheet active | full URL of the target Google Sheet, e.g. https://docs.google.com/spreadsheets/d/<id>/edit?gid=<gid> — both the spreadsheet ID and the tab's gid are parsed from it | | JIRA_TEAM_USERS_FOR_GOOGLE_SHEET | sheet active | comma-separated subset of JIRA_TEAM_USERS to write to the sheet — every entry must also appear in JIRA_TEAM_USERS, checked up front | | GOOGLE_SERVICE_ACCOUNT_KEY_B64 | sheet active | base64 of the service account's JSON key (base64 -w0 key.json) — see Google Sheet setup below | | SUPPORT_EMAIL | optional | if set, receives an email alert whenever the CLI errors out (e.g. an expired JIRA_API_TOKEN), independent of which delivery channel(s) are active — requires SMTP_* and EMAIL_FROM to also be set, since it's sent over the same SMTP relay |

Missing required variables print a single combined Missing required env variables: ... message and exit with code 1 — only the active channel's variables are checked, so email-only setups don't need Slack variables set. For Slack, only one of the two credential shapes is required; if both SLACK_BOT_TOKEN and SLACK_WEBHOOK_URL are set, the bot token is used. When sheet is active, JIRA_TEAM_USERS_FOR_GOOGLE_SHEET is additionally checked against JIRA_TEAM_USERS up front — a sheet-roster entry with no matching roster entry throws a separate, specific error rather than surfacing later as a missing column in the sheet.

Google Sheet setup

Writing to the sheet uses a Google Cloud service account, not a personal OAuth login — it's the right shape for an unattended cron write, and access is scoped to just this one sheet:

  1. console.cloud.google.com → create or pick a project → APIs & Services → Library → enable the Google Sheets API.
  2. APIs & Services → Credentials → Create Credentials → Service account. No project-level IAM role is needed — access to the sheet is granted in step 4, not here.
  3. Open the service account → Keys → Add Key → Create new key → JSON. This downloads the private key — handle it like every other credential in this repo (never commit it, never paste its contents anywhere).
  4. Copy the service account's email (...@<project-id>.iam.gserviceaccount.com) → open the actual sheet at GOOGLE_SHEET_URL → Share → paste that email → grant Editor → Share. This is the only place this credential gets write access.
  5. base64 -w0 path/to/downloaded-key.json, paste the output as GOOGLE_SERVICE_ACCOUNT_KEY_B64 in .env, then delete the downloaded .json file — the env var becomes the only copy.
  6. In the sheet itself, row 1 needs a header cell containing each JIRA_TEAM_USERS_FOR_GOOGLE_SHEET member's email — that's what column resolution matches against.

If the key ever leaks, delete it from the service account's Keys tab, generate a new one, and repeat steps 3 and 5 — no re-sharing needed, since the service account's identity doesn't change.

Failure alerts

If SUPPORT_EMAIL is set, any error that stops a run — a Jira API failure, a missing/expired credential, all configured notifiers failing to send — triggers a best-effort email to that address with the error message and stack trace, on top of the normal console error output and non-zero exit code. This is meant for unattended cron runs (see below), where nobody is watching stdout. If sending the alert itself fails (e.g. SMTP is down), that failure is logged to stderr and swallowed — it never replaces or masks the original error, and never changes the exit code.

If slack is one of the active notifiers and the run was a --send run (not a manual dry-run test), the same kind of error also gets a plain-text :warning: post in the same Slack channel the standup itself would have used — e.g. a roster member Jira can't resolve, so the team sees the failure where they're already watching, not only via SUPPORT_EMAIL's separate inbox. Same best-effort guarantee: a failure posting the alert itself is logged and swallowed, never replacing or masking the original error.

Usage

agent-scrum-master run                                            # today, dry run (preview only)
agent-scrum-master run --date 2026-08-03                          # a specific past day
agent-scrum-master run --since 2026-08-01                         # catch-up: 2026-08-01 through today, one report
agent-scrum-master run --since 2026-08-01 --date 2026-08-03       # catch-up: a specific range
agent-scrum-master run --since-hours 24                           # rolling window: the last 24 hours, ending now
agent-scrum-master run --projects PROJ1,PROJ2                     # override JIRA_PROJECTS
agent-scrum-master run --channel-type email                       # force email only for this run
agent-scrum-master run --channel-type email --to [email protected]  # override EMAIL_TO for testing
agent-scrum-master run --channel-type slack --channel C0TESTCHAN  # override SLACK_CHANNEL for testing
agent-scrum-master run --channel-type sheet                       # preview the Google Sheet row only
agent-scrum-master run --send                                     # actually deliver

Example dry-run output:

--- Email preview (to: [email protected]) ---
Daily Standup — Mon, Aug 3 2026

PROJ1
  Alice
    PROJ-123: Fix login redirect loop (created, commented) [In Progress / Dev to Pick] — "Fixed, moving to QA"
    PROJ-145: Update onboarding flow copy (updated) [Done / Prod Verification]

PROJ2
  Bob
    PROJ2-150: Flaky checkout test (commented) [On Hold / Blocked] — "can someone check the CI logs?"

(dry run at 2026-08-03 09:30:12 — pass --send to deliver)

The report is grouped by project first, then by teammate within it — a project section is omitted entirely if nobody had activity in it that day (no "no tracked activity" placeholders repeated per teammate). Each issue line shows its current status and sub-status, and — when the teammate on that line commented themselves in the reporting window — a truncated snippet of their own comment (COMMENT_SNIPPET_LENGTH, default 50 characters). If they left more than one comment on the same issue in the window, the snippet is their latest one, not their first.

"Updated" activity comes from Jira's changelog — every field change on an issue, not just status. EXCLUDE_CHANGELOG_FIELDS (default assignee) drops changelog entries made up entirely of excluded fields, so a pure reassignment doesn't count as activity for whoever performed it; a reassignment bundled with a real change (e.g. also moving the status) still counts, since real work happened too.

If a teammate's issue list within a project exceeds ISSUE_DISPLAY_LIMIT (default 5) — e.g. a regression pass touching 50 tickets in one day — only the first ISSUE_DISPLAY_LIMIT issues are shown inline; the rest collapse into a "+ N more" link to a Jira search listing exactly those issue keys (key in (...), not an attribution guess). Slack has no native collapsible section within a message and HTML email's <details> doesn't render reliably across clients, so linking out to Jira is the closest equivalent to expand/collapse without standing up an interactivity endpoint.

--since switches from a single day to a range — activity across every day in [--since, --date or today] is merged into one report (still one line per issue per teammate, not one per day), for catching up after a weekend or a missed cron run. The header becomes e.g. "Aug 1 – Aug 4, 2026" instead of a single date. Without --since, behavior is unchanged (single day, --since defaults to the same value as the report date).

--since-hours N is a different kind of window: an exact rolling span of the last N hours ending right now, rather than a whole calendar day. A cron job running at 8pm with --since-hours 24 reports exactly "8pm yesterday through 8pm today," not "today so far" or "yesterday" — useful when cron fires more than once a day and each run should cover precisely the time since the previous one, regardless of where that falls relative to midnight. The header shows the exact cutoff times, e.g. "Aug 10, 8:00 PM – Aug 11, 8:00 PM". Cannot be combined with --date or --since — pick one way of specifying the window per run. Internally, the Jira search itself still queries whole calendar days (Jira's JQL date literals don't have finer granularity), padded to a guaranteed superset of the requested window; the exact N-hour cutoff is then enforced by comparing each change/comment's real timestamp against it, not by the query.

Google Sheet

When sheet is an active delivery channel, each run appends one new row to GOOGLE_SHEET_URL's tab: column A is the report date (YYYY-MM-DD, report.dateISO — the end of the reporting window for --date/--since, or the calendar date "now" falls on for --since-hours), and each JIRA_TEAM_USERS_FOR_GOOGLE_SHEET member gets their own column, resolved by matching their email against row 1's header cells (not a fixed B/C/D position — a manually reordered or inserted sheet column doesn't misattribute data). A member's cell holds every issue they touched that run, across every project (not grouped by project like Slack/email), one KEY: Title line per issue, newline-separated — plus a truncated comment snippet (COMMENT_SNIPPET_LENGTH) appended as KEY: Title - snippet for any issue they also commented on.

Always appends a new row, even on a rerun for an already-reported date — there's no upsert lookup by date, so a manual rerun or retry produces a duplicate row for that day, not an overwrite.

Set "wrap text" on the per-member columns in the sheet once, manually — otherwise a multi-issue cell's embedded newlines display as literal breaks without visibly wrapping.

Deleting a bad post

agent-scrum-master delete-post --link https://yourteam.slack.com/archives/C0123456789/p1234567890123456

Deletes a single Slack message the bot posted — for cleaning up a duplicate, a test send, or a report that went out with stale data, without needing separate Slack admin access. Get the link via a message's ⋯ More actions → Copy link in Slack; the permalink's p<digits> segment encodes the message's channel and timestamp, which is all chat.delete needs. Requires SLACK_BOT_TOKEN — an incoming webhook has no identity to delete with. Slack's API only allows a bot to delete messages it posted itself, so this can't be pointed at someone else's message.

Safety notes

  • --send is required to actually deliver. Without it, every active notifier's preview is printed and nothing is sent — this is the default, and there is no interactive confirmation prompt, since the tool is meant to eventually run unattended from cron.
  • delete-post has no such dry-run gate — passing --link deletes immediately, same as every other action in this CLI: an explicit flag is the confirmation, there's no separate prompt. Double-check the permalink before running it; Slack restricting deletion to the bot's own messages is the only safety net.
  • Use --channel / --to to redirect a real --send run to a private test channel or personal inbox before pointing it at the real SLACK_CHANNEL / EMAIL_TO.
  • If one notifier fails and another is active, the tool still attempts both and reports every failure together — a Slack outage won't silently swallow an email failure or vice versa.

Cron

Three scripts wrap run --send for unattended scheduling, all meant to run Monday–Friday only (skip weekends — nothing new to report until Monday's catch-up run). Pick either the whole-day pair or the rolling-window script — don't mix both approaches for the same schedule, since that'd double-report activity.

Whole-day (calendar date boundaries):

  • scripts/daily-standup.sh — a morning report of yesterday's activity. Cron can't compute "yesterday" vs. "last Friday" itself, so the script picks the report window based on day of week: Monday reports a range since the prior Friday (covers the Fri/Sat/Sun gap through Monday itself), Tuesday–Friday report a single day — the prior day only.
  • scripts/daily-standup-eod.sh — an evening snapshot of today's activity so far. No day-of-week branching needed here since run already defaults --date to today; it's an interim look at the day in progress, not a catch-up. The same day gets reported again the next morning by daily-standup.sh (by then as "yesterday"), so expect overlap between the two — that's intentional, the morning run is the settled final version of what the evening run previewed.

Rolling window (exact elapsed time, e.g. 8pm-to-8pm):

  • scripts/daily-standup-rolling.sh — reports the last STANDUP_WINDOW_HOURS hours (default 24) ending right now, rather than a whole calendar day. Used for both the morning and evening cron entries (the logic is identical either way — only the cron time, and so what "now" resolves to, differs), so a morning 7am run and an evening 8pm run don't overlap: each covers the 24 hours since the other one's last run. Extends to STANDUP_WEEKEND_WINDOW_HOURS (default 72) on Monday, same reasoning as the whole-day pair's weekend catch-up.

All three force their date/day-of-week arithmetic to REPORT_TIMEZONE (default Asia/Kolkata) regardless of the cron host's own timezone, so scheduling stays consistent with the tool's own default.

chmod +x scripts/daily-standup.sh scripts/daily-standup-eod.sh scripts/daily-standup-rolling.sh   # already executable if cloned from this repo
crontab -e

Whole-day pair, morning at 7am and evening at 9pm, Monday–Friday:

0  7 * * 1-5 /absolute/path/to/agent-scrum-master/scripts/daily-standup.sh     >> /absolute/path/to/agent-scrum-master/cron.log 2>&1
0 21 * * 1-5 /absolute/path/to/agent-scrum-master/scripts/daily-standup-eod.sh >> /absolute/path/to/agent-scrum-master/cron.log 2>&1

Rolling-window alternative, same schedule (same script both times):

0  7 * * 1-5 /absolute/path/to/agent-scrum-master/scripts/daily-standup-rolling.sh >> /absolute/path/to/agent-scrum-master/cron.log 2>&1
0 20 * * 1-5 /absolute/path/to/agent-scrum-master/scripts/daily-standup-rolling.sh >> /absolute/path/to/agent-scrum-master/cron.log 2>&1

Notes:

  • Use absolute paths — cron runs with a minimal environment and no shell profile, and each script's own cd "$(dirname "$0")/.." only resolves correctly if cron invokes it by full path.
  • .env loads automatically (via dotenv/config in src/index.ts) as long as the script's cd succeeds — no extra env setup needed in the crontab itself.
  • Make sure node is on cron's PATH (cron's default PATH is often just /usr/bin:/bin) — if node was installed via nvm, either symlink it onto a path cron already searches, or set PATH explicitly in the crontab (PATH=/home/you/.nvm/versions/node/v22.x.x/bin:/usr/bin:/bin).
  • Check cron.log after the first scheduled run of each to confirm delivery before trusting it unattended.

Compliance note

Between Jira Cloud and Slack/email, this tool is a stateless pass-through — it adds no storage layer of its own. The real risk is ticket titles and comment text: if an issue is mistitled, or commented on, with policyholder- or customer-identifying text, this relays it verbatim into a shared channel or inbox. Keep PII/PHI out of Jira ticket titles and comments — this tool does not scan or redact content.

The sheet channel raises the stakes on the same risk: unlike a Slack post or an email, a Google Sheet row is an append-only permanent record, not an ephemeral message — the same mistitled-ticket text becomes a durable spreadsheet line, not something that scrolls out of view. Confirm the sheet stays private (not "anyone with the link"), and confirm your Google Workspace domain's data-region policy if this tool is used for a regulated-client engagement — Sheets storage location is controlled by that Workspace setting, not by this tool.

Known limitations

  • Jira Cloud's per-account privacy setting can hide emailAddress from user search results — attribution is keyed on accountId, resolved once per run via /rest/api/3/user/search, not on email string matching against changelog/comment authors.
  • Jira evaluates dates in the Jira instance's configured timezone, not the timezone of the machine running this CLI or the machine's UTC clock — when --date isn't passed, "today" is resolved via REPORT_TIMEZONE (default Asia/Kolkata) for exactly this reason; set it to match your Jira instance if that's not Asia/Kolkata.
  • The old startAt-based /rest/api/3/search Jira endpoint has been retired; this tool uses the current cursor-based /rest/api/3/search/jql endpoint.
  • Jira's JQL project in (...) clause doesn't error on an unknown project key — it just matches zero issues for it, silently. JIRA_PROJECTS/--projects keys are checked against GET /rest/api/3/project/{key} up front for exactly this reason, so a typo throws instead of quietly dropping that project's section every day.