@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 buildRun 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:
- console.cloud.google.com → create or pick a project → APIs & Services → Library → enable the Google Sheets API.
- 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.
- 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).
- Copy the service account's email (
...@<project-id>.iam.gserviceaccount.com) → open the actual sheet atGOOGLE_SHEET_URL→ Share → paste that email → grant Editor → Share. This is the only place this credential gets write access. base64 -w0 path/to/downloaded-key.json, paste the output asGOOGLE_SERVICE_ACCOUNT_KEY_B64in.env, then delete the downloaded.jsonfile — the env var becomes the only copy.- In the sheet itself, row 1 needs a header cell containing each
JIRA_TEAM_USERS_FOR_GOOGLE_SHEETmember'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 deliverExample 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/p1234567890123456Deletes 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
--sendis 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-posthas no such dry-run gate — passing--linkdeletes 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/--toto redirect a real--sendrun to a private test channel or personal inbox before pointing it at the realSLACK_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 sincerunalready defaults--dateto today; it's an interim look at the day in progress, not a catch-up. The same day gets reported again the next morning bydaily-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 lastSTANDUP_WINDOW_HOURShours (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 toSTANDUP_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 -eWhole-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>&1Rolling-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>&1Notes:
- 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. .envloads automatically (viadotenv/configinsrc/index.ts) as long as the script'scdsucceeds — no extra env setup needed in the crontab itself.- Make sure
nodeis on cron'sPATH(cron's defaultPATHis often just/usr/bin:/bin) — ifnodewas installed vianvm, either symlink it onto a path cron already searches, or setPATHexplicitly in the crontab (PATH=/home/you/.nvm/versions/node/v22.x.x/bin:/usr/bin:/bin). - Check
cron.logafter 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
emailAddressfrom user search results — attribution is keyed onaccountId, 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
--dateisn't passed, "today" is resolved viaREPORT_TIMEZONE(defaultAsia/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/searchJira endpoint has been retired; this tool uses the current cursor-based/rest/api/3/search/jqlendpoint. - Jira's JQL
project in (...)clause doesn't error on an unknown project key — it just matches zero issues for it, silently.JIRA_PROJECTS/--projectskeys are checked againstGET /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.
