standup-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sathvic Kollu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # standup-mcp
2
+
3
+ **Generate your daily standup from what you actually did, across GitHub, Jira, Linear, and Slack.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/standup-mcp.svg)](https://www.npmjs.com/package/standup-mcp)
6
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
8
+ [![MCP](https://img.shields.io/badge/Model%20Context%20Protocol-server-7b5cff.svg)](https://modelcontextprotocol.io)
9
+
10
+ Every standup, you stop and reconstruct yesterday from memory: which commits, which PR, which ticket you moved, that Slack thread where you said you were stuck. The work is already recorded across your tools. Re-typing it is the chore everyone hates, so updates come out vague ("worked on the thing") and real blockers go unsaid.
11
+
12
+ `standup-mcp` reads that activity back and writes the draft for you. You ask your AI assistant "what should I say at standup today?" and it answers from your real GitHub, Jira, Linear, and Slack activity: grouped by work item, in concrete deltas, with blockers it noticed on its own.
13
+
14
+ It is a standard [Model Context Protocol](https://modelcontextprotocol.io) server, so it works in any MCP client (Claude, Cursor, Cline, and more) on any model. It is **local and read-only**: nothing about your activity leaves your machine except the calls to those tools' own APIs, and it needs **no AI API key of its own**. The host client supplies the model.
15
+
16
+ ## See it in 30 seconds (no accounts needed)
17
+
18
+ The server ships with a realistic demo dataset and runs against it automatically when no credentials are set, so you see the output before connecting anything:
19
+
20
+ ```bash
21
+ npx -y standup-mcp --demo
22
+ ```
23
+
24
+ That prints a full standup from a synthetic day across all four tools. Here is the headline tool:
25
+
26
+ ```
27
+ # Standup: Jordan Lee
28
+ _since Tue Jun 16. Demo data, no credentials configured._
29
+
30
+ ## Yesterday
31
+ - **PROJ-412 Biometric re-auth** · opened PR #128, 3 commits, latest "handle expired challenge edge case", moved to In Progress, posted an update
32
+ - **ENG-88 Rate-limit the export endpoint** · 2 commits, latest "tests for limiter window", moved to In Progress, posted an update
33
+ - **PROJ-407 Card dispute webhook** · merged PR #125, moved to In Review
34
+ - Reviewed PR #129: Tidy currency formatting helpers
35
+
36
+ ## Today
37
+ - Continue **PROJ-412 Biometric re-auth**
38
+ - Continue **ENG-88 Rate-limit the export endpoint**
39
+ - Land PR #128: Add biometric re-auth (review pending)
40
+ - Review PR #130: Refactor request logging
41
+
42
+ ## Blockers
43
+ - 🔴 **PROJ-412 Biometric re-auth** · flagged blocked: "Blocked on the vendor sandbox creds for PROJ-412. Waiting on infra to provision them before I can test the re-auth flow…"; awaiting review
44
+ ```
45
+
46
+ Notice the GitHub commits, the GitHub PR, and the Jira move all collapsed under **PROJ-412**, and the blocker was lifted from a Slack message on its own. Nobody typed any of that.
47
+
48
+ (Running `npx -y standup-mcp` with no flag starts the MCP server on stdio, which is what an MCP client launches. Use `--demo` to see output in a plain terminal.)
49
+
50
+ ## What it does
51
+
52
+ Five tools. Every one defaults to the last working day, and Monday automatically reaches back across the weekend, so you rarely pass arguments.
53
+
54
+ | Tool | What you get |
55
+ | --- | --- |
56
+ | `standup_draft` | Your standup: Yesterday / Today / Blockers, grouped by work item, in concrete deltas. The answer to "what should I say at standup?" |
57
+ | `blocker_scan` | Everything blocking you, ranked by severity, fused from explicit "blocked / waiting on" language, PRs awaiting review, stalled in-progress tickets, and help requests. |
58
+ | `weekly_summary` | A week of activity rolled into Shipped vs In progress, with reviews and totals. For 1:1s, weekly status, and self-reviews. |
59
+ | `activity_digest` | A chronological "what actually happened" across all tools, newest first, grouped by day. |
60
+ | `list_sources` | Which sources are wired, or that you are on demo data. |
61
+
62
+ ### What you can ask
63
+
64
+ You talk to it in plain language through your AI client:
65
+
66
+ - "What should I say at standup today?"
67
+ - "Give me Monday's standup covering the weekend."
68
+ - "What is blocking me right now?"
69
+ - "Summarize what I shipped this week for my 1:1."
70
+ - "What did I actually do yesterday?"
71
+
72
+ ### Principles it follows
73
+
74
+ - **Only observed activity.** It reports commits, PRs, ticket moves, and messages that exist. It never invents progress to fill a quiet day; a quiet day is reported as one.
75
+ - **Grouped by work item, not by tool.** Reviewers think in tickets, so the commit, the PR, the Jira move, and the Slack thread for PROJ-412 become one line, not four.
76
+ - **Blockers from signals, not self-report.** People forget to say they are stuck, so it infers it.
77
+ - **A draft, not an auto-post.** You get editable markdown to paste wherever your team already does standup (Slack, Geekbot, a doc, a ticket). It does not post on your behalf.
78
+
79
+ <details>
80
+ <summary><b>Example: blocker_scan</b></summary>
81
+
82
+ ```
83
+ # Blockers
84
+ _since Tue Jun 16. Demo data._
85
+
86
+ **1 blocker**: 1 high, 0 medium, 0 low.
87
+
88
+ - 🔴 **PROJ-412 Biometric re-auth** (slack) · flagged blocked: "Blocked on the vendor sandbox creds for PROJ-412. Waiting on infra to provision them before I can test the re-auth flow…"; awaiting review
89
+ ```
90
+ </details>
91
+
92
+ <details>
93
+ <summary><b>Example: weekly_summary</b></summary>
94
+
95
+ ```
96
+ # Weekly summary: Jordan Lee
97
+ _the last 7 days. Demo data._
98
+
99
+ ## Shipped
100
+ - **PROJ-407 Card dispute webhook**
101
+
102
+ ## In progress
103
+ - **PROJ-412 Biometric re-auth**
104
+ - **ENG-88 Rate-limit the export endpoint**
105
+
106
+ ## Reviews and support
107
+ - Reviewed 1 PR for teammates
108
+
109
+ _3 work items touched, 1 PR merged, 5 commits._
110
+ ```
111
+ </details>
112
+
113
+ ## Privacy
114
+
115
+ This is the part most tools gloss over. `standup-mcp` is built so that using it does not feel like installing surveillance on yourself:
116
+
117
+ - **Local.** It runs on your machine, inside your AI client. There is no standup-mcp server or account.
118
+ - **Read-only.** Every token it asks for is used only to read your activity. It never writes, posts, or moves anything.
119
+ - **No AI key, no third party.** It makes no LLM calls of its own. Your activity is sent only to the APIs of the tools you connect, and to your existing AI client's model. It is not sent to me or anyone else.
120
+ - **It is yours, not your manager's.** It generates your own update for you to review and edit, not a feed of your activity for someone else.
121
+
122
+ ## Connect your tools
123
+
124
+ Set any subset. Whatever you configure, it uses; with nothing set, it stays in demo mode. Restart the server after changing these.
125
+
126
+ | Variable | Source | Notes |
127
+ | --- | --- | --- |
128
+ | `GITHUB_TOKEN` | GitHub | Read-only PAT. Reads your commits, PRs, and reviews. |
129
+ | `JIRA_BASE_URL` `JIRA_EMAIL` `JIRA_API_TOKEN` | Jira | Cloud site, account email, and an [API token](https://id.atlassian.com/manage-profile/security/api-tokens). Reads your ticket moves. |
130
+ | `LINEAR_API_KEY` | Linear | A [personal API key](https://linear.app/settings/api). Reads your issue state changes and comments. |
131
+ | `SLACK_TOKEN` | Slack | A user token (`xoxp`) with `search:read` scans your own messages for blocker language. With a bot token instead, also set `SLACK_CHANNELS` (comma-separated channel ids) since bots cannot search. |
132
+ | `STANDUP_NAME` | optional | Display name for the standup's owner. Without it, the GitHub handle is used. |
133
+
134
+ Verify your connections before wiring it into a client:
135
+
136
+ ```bash
137
+ GITHUB_TOKEN=ghp_xxx LINEAR_API_KEY=lin_xxx npx -y standup-mcp --check
138
+ ```
139
+
140
+ It prints, per source, the authenticated identity or a clear error.
141
+
142
+ ## Connect your AI client
143
+
144
+ `standup-mcp` speaks the Model Context Protocol, so any MCP-capable client can use it, whichever model is behind it: Claude Desktop, Claude Code, Cursor, Cline, Continue, Zed, Windsurf, and more. The server uses no AI API key of its own.
145
+
146
+ ### Claude Desktop
147
+
148
+ Add this to `claude_desktop_config.json` (Settings, Developer, Edit Config), then restart Claude Desktop:
149
+
150
+ ```json
151
+ {
152
+ "mcpServers": {
153
+ "standup": {
154
+ "command": "npx",
155
+ "args": ["-y", "standup-mcp"],
156
+ "env": {
157
+ "GITHUB_TOKEN": "ghp_your_token",
158
+ "JIRA_BASE_URL": "https://your-company.atlassian.net",
159
+ "JIRA_EMAIL": "you@company.com",
160
+ "JIRA_API_TOKEN": "your-jira-token",
161
+ "LINEAR_API_KEY": "lin_api_your_key",
162
+ "SLACK_TOKEN": "xoxp-your-token"
163
+ }
164
+ }
165
+ }
166
+ }
167
+ ```
168
+
169
+ Leave the `env` block out entirely to run in demo mode first. Include only the sources you use.
170
+
171
+ ### Claude Code
172
+
173
+ ```bash
174
+ claude mcp add standup \
175
+ -e GITHUB_TOKEN=ghp_your_token \
176
+ -e LINEAR_API_KEY=lin_api_your_key \
177
+ -- npx -y standup-mcp
178
+ ```
179
+
180
+ ### Cursor, Cline, Continue, Zed, Windsurf, and others
181
+
182
+ These read the same `mcpServers` JSON as Claude Desktop, in the client's own MCP config. Use the block above. The server is identical; only the model driving the client differs.
183
+
184
+ ## How it works
185
+
186
+ The design goal is a clean seam between each tool and the standup logic.
187
+
188
+ - **One activity model.** GitHub commits, Jira moves, Linear state changes, and Slack messages all normalize to a single `ActivityEvent`. Nothing downstream knows which tool a fact came from, which is exactly what lets it group by work item across tools.
189
+ - **One provider, many sources.** Each source is an independent read-only client behind a common interface. The aggregator fans out to whichever are configured and tolerates any one failing, so a misconfigured Slack token never sinks your standup.
190
+ - **Pure-function engine.** Grouping, blocker detection, and the draft are pure functions over normalized events. They run identically on demo data and live data, and the tests run them directly.
191
+ - **No model in the server.** The server assembles a factual draft; your AI client phrases it. That is why it needs no AI key and why it can promise it never invents work.
192
+
193
+ ```
194
+ src/
195
+ index.ts MCP server, stdio transport, --demo/--check/--help
196
+ config.ts per-source env resolution, demo-mode detection
197
+ window.ts the weekend-aware "since last standup" window
198
+ provider.ts aggregator that fans out to configured sources
199
+ types.ts ActivityEvent and the source/provider interfaces
200
+ normalize.ts work-item key extraction, signal and noise detection
201
+ sources/ github, jira, linear, slack clients, plus the demo provider
202
+ analytics/ grouping, blockers, draft, weekly, digest (pure functions)
203
+ tools/ one MCP tool per file, thin wrappers over analytics
204
+ ```
205
+
206
+ ## What has been verified
207
+
208
+ - All five tools run end to end over real MCP stdio (`npm test`).
209
+ - The parse heuristics (work-item keys, blocker language, noise filters) are unit tested (`test/normalize.test.ts`).
210
+ - The engine is unit tested, including the Monday-covers-the-weekend window, work-item grouping, blocker severity, and the assembled draft (`test/draft.test.ts`).
211
+ - The whole engine is exercised against the demo dataset and reconciles across tools (`npm run smoke`).
212
+
213
+ The demo dataset proves the engine. It does not prove each live client against every real account shape, which is why the parse layer is unit tested separately and each source is kept small and tolerant. Run `--check` to confirm your own connections, and if a response shape does not parse cleanly on your account, open an issue.
214
+
215
+ ## Roadmap
216
+
217
+ - A `team_standup` view that rolls up several people for the PM running the meeting
218
+ - Calendar sources (meetings as activity, so a meeting-heavy day reads honestly)
219
+ - GitLab and Bitbucket
220
+ - Cycle-time and "what changed since I last looked" digests
221
+ - OAuth flows in addition to tokens
222
+
223
+ ## Built by Sathvic Kollu
224
+
225
+ I run delivery for SaaS and fintech teams, and I build tools like this with Claude Code. If this saves you the daily standup chore, I would like to hear how you use it.
226
+
227
+ - Website: [sathvickollu.com](https://sathvickollu.com)
228
+ - LinkedIn: [linkedin.com/in/sathvic-kollu](https://www.linkedin.com/in/sathvic-kollu)
229
+
230
+ Issues and pull requests are welcome.
231
+
232
+ ## License
233
+
234
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Blocker detection by signal fusion, not self-report.
3
+ *
4
+ * People forget to say they are blocked. So instead of asking, we infer it from
5
+ * what the tools already show: explicit "blocked / waiting on" language, a PR
6
+ * that has been awaiting review, an in-progress ticket that has not moved, or a
7
+ * request for help. Each candidate event/open-item carries Signal[]; we map
8
+ * those to a severity and a human reason, then rank.
9
+ */
10
+ import { daysBetween, heading, plural } from "../format.js";
11
+ import { truncate } from "../normalize.js";
12
+ const RANK = { high: 3, medium: 2, low: 1 };
13
+ function severityForSignal(signal, ageDays) {
14
+ switch (signal) {
15
+ case "blocked":
16
+ return "high";
17
+ case "review_pending":
18
+ return ageDays >= 2 ? "high" : "medium";
19
+ case "stale":
20
+ return "medium";
21
+ case "help_requested":
22
+ return "low";
23
+ }
24
+ }
25
+ function reasonForSignal(signal, ageDays, body) {
26
+ switch (signal) {
27
+ case "blocked": {
28
+ const snippet = body ? truncate(body, 120) : "";
29
+ return snippet ? `flagged blocked: "${snippet}"` : "flagged as blocked";
30
+ }
31
+ case "review_pending":
32
+ return ageDays >= 1
33
+ ? `awaiting review for ${ageDays}d`
34
+ : "awaiting review";
35
+ case "stale":
36
+ return ageDays >= 1
37
+ ? `in progress ${ageDays}d with no recent movement`
38
+ : "in progress with no recent movement";
39
+ case "help_requested":
40
+ return "asked for help, no answer yet";
41
+ }
42
+ }
43
+ /**
44
+ * Detect blockers across this window's activity and the current open items.
45
+ * `nowIso` anchors staleness/age math.
46
+ */
47
+ export function detectBlockers(events, openItems, nowIso) {
48
+ const all = [...events, ...openItems];
49
+ const candidates = all.filter((e) => e.signals.length > 0);
50
+ // Borrow the real work-item title from any event that has one, so a blocker
51
+ // surfaced from a Slack message (which has no title) still reads as the
52
+ // ticket it is about, not the raw message text.
53
+ const titles = new Map();
54
+ for (const e of all) {
55
+ if (e.workItem && e.workItemTitle && !titles.has(e.workItem)) {
56
+ titles.set(e.workItem, e.workItemTitle);
57
+ }
58
+ }
59
+ // Dedupe by work item (or title) so the same blocked ticket surfaced from two
60
+ // sources collapses to one entry, merging reasons.
61
+ const byKey = new Map();
62
+ for (const e of candidates) {
63
+ const ageDays = daysBetween(e.timestamp, nowIso);
64
+ let severity = "low";
65
+ const reasons = [];
66
+ for (const sig of e.signals) {
67
+ const sev = severityForSignal(sig, ageDays);
68
+ if (RANK[sev] > RANK[severity])
69
+ severity = sev;
70
+ reasons.push(reasonForSignal(sig, ageDays, e.body));
71
+ }
72
+ if (reasons.length === 0)
73
+ continue;
74
+ const dedupeKey = e.workItem ?? e.title;
75
+ const existing = byKey.get(dedupeKey);
76
+ if (existing) {
77
+ for (const r of reasons) {
78
+ if (!existing.reasons.includes(r))
79
+ existing.reasons.push(r);
80
+ }
81
+ if (RANK[severity] > RANK[existing.severity])
82
+ existing.severity = severity;
83
+ }
84
+ else {
85
+ byKey.set(dedupeKey, {
86
+ key: e.workItem,
87
+ title: (e.workItem && titles.get(e.workItem)) ?? e.workItemTitle ?? e.title,
88
+ severity,
89
+ reasons,
90
+ source: e.source,
91
+ url: e.url,
92
+ });
93
+ }
94
+ }
95
+ return [...byKey.values()].sort((a, b) => RANK[b.severity] - RANK[a.severity]);
96
+ }
97
+ const SEV_ICON = {
98
+ high: "🔴",
99
+ medium: "🟡",
100
+ low: "🔵",
101
+ };
102
+ /** Standalone blocker report, used by the blocker_scan tool and the demo. */
103
+ export function formatBlockerReport(blockers, window, isDemo) {
104
+ const out = [];
105
+ out.push(heading(1, "Blockers"));
106
+ out.push(`_${window.label}${isDemo ? ". Demo data." : ""}_`);
107
+ out.push("");
108
+ if (blockers.length === 0) {
109
+ out.push("No blockers detected. Nothing is flagged, stalled, or awaiting a review.");
110
+ return out.join("\n");
111
+ }
112
+ const high = blockers.filter((b) => b.severity === "high").length;
113
+ const med = blockers.filter((b) => b.severity === "medium").length;
114
+ const low = blockers.filter((b) => b.severity === "low").length;
115
+ out.push(`**${plural(blockers.length, "blocker")}**: ${high} high, ${med} medium, ${low} low.`);
116
+ out.push("");
117
+ for (const b of blockers) {
118
+ const label = b.key ? `${b.key} ${b.title}` : b.title;
119
+ out.push(`- ${SEV_ICON[b.severity]} **${truncate(label, 80)}** (${b.source}) · ${b.reasons.join("; ")}`);
120
+ }
121
+ return out.join("\n");
122
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Chronological digest: "what actually happened" in a window, newest first,
3
+ * grouped by day. Useful for reconstructing a stretch of work or filling in a
4
+ * timesheet, separate from the opinionated standup framing.
5
+ */
6
+ import { fmtDayDate, fmtTime, heading, sourceLabel, plural } from "../format.js";
7
+ import { truncate } from "../normalize.js";
8
+ function dayKey(iso) {
9
+ return iso.slice(0, 10); // YYYY-MM-DD (UTC)
10
+ }
11
+ export function buildDigest(events, window, isDemo = false) {
12
+ const out = [];
13
+ out.push(heading(1, "Activity digest"));
14
+ out.push(`_${window.label}${isDemo ? ". Demo data." : ""}_`);
15
+ out.push("");
16
+ if (events.length === 0) {
17
+ out.push("_No tracked activity in this window._");
18
+ return out.join("\n");
19
+ }
20
+ const sorted = [...events].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
21
+ let currentDay = "";
22
+ for (const e of sorted) {
23
+ const dk = dayKey(e.timestamp);
24
+ if (dk !== currentDay) {
25
+ currentDay = dk;
26
+ out.push("");
27
+ out.push(heading(2, fmtDayDate(e.timestamp)));
28
+ }
29
+ const item = e.workItem ? `${e.workItem} · ` : "";
30
+ out.push(`- ${fmtTime(e.timestamp)} [${sourceLabel(e.source)}] ${item}${truncate(e.title, 100)}`);
31
+ }
32
+ out.push("");
33
+ out.push(`_${plural(events.length, "event")} across the window._`);
34
+ return out.join("\n");
35
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Assemble the standup draft: Yesterday / Today / Blockers, grouped by work
3
+ * item, in concrete deltas.
4
+ *
5
+ * This is deterministic. The server makes no LLM call; it turns observed events
6
+ * into a clean, paste-ready draft and never invents work that is not in the
7
+ * data. The host AI client can rephrase the tone, but the facts come from here.
8
+ */
9
+ import { groupByWorkItem, OTHER_KEY } from "./grouping.js";
10
+ import { detectBlockers } from "./blockers.js";
11
+ import { plural, heading } from "../format.js";
12
+ import { truncate } from "../normalize.js";
13
+ const SEV_ICON = { high: "🔴", medium: "🟡", low: "🔵" };
14
+ /** Pull the "#123" reference out of an event title, if present. */
15
+ function ref(e) {
16
+ const m = e.title.match(/#\d+/);
17
+ return m ? ` ${m[0]}` : "";
18
+ }
19
+ /** Collapse a work item's events into one concrete delta line. */
20
+ function summarizeGroup(group) {
21
+ const e = group.events;
22
+ const parts = [];
23
+ const merged = e.filter((x) => x.kind === "pr_merged");
24
+ const opened = e.filter((x) => x.kind === "pr_opened");
25
+ const reviewed = e.filter((x) => x.kind === "pr_reviewed");
26
+ const commits = e.filter((x) => x.kind === "commit");
27
+ const moves = e.filter((x) => x.kind === "issue_moved");
28
+ const created = e.filter((x) => x.kind === "issue_created");
29
+ const talked = e.filter((x) => x.kind === "issue_commented" || x.kind === "message");
30
+ for (const m of merged)
31
+ parts.push(`merged PR${ref(m)}`);
32
+ for (const o of opened)
33
+ parts.push(`opened PR${ref(o)}`);
34
+ if (commits.length) {
35
+ const latest = commits[commits.length - 1];
36
+ const tail = commits.length <= 3 ? `, latest "${truncate(latest.title, 56)}"` : "";
37
+ parts.push(`${plural(commits.length, "commit")}${tail}`);
38
+ }
39
+ if (created.length)
40
+ parts.push("created the ticket");
41
+ if (moves.length) {
42
+ const last = moves[moves.length - 1];
43
+ if (last.toStatus)
44
+ parts.push(`moved to ${last.toStatus}`);
45
+ else
46
+ parts.push("status updated");
47
+ }
48
+ for (const r of reviewed)
49
+ parts.push(`reviewed PR${ref(r)}`);
50
+ if (talked.length)
51
+ parts.push("posted an update");
52
+ return parts.join(", ") || "activity recorded";
53
+ }
54
+ function renderYesterday(groups) {
55
+ const lines = [];
56
+ for (const g of groups) {
57
+ if (g.key === OTHER_KEY)
58
+ continue;
59
+ const label = g.title ? `${g.key} ${g.title}` : g.key;
60
+ lines.push(`- **${label}** · ${summarizeGroup(g)}`);
61
+ }
62
+ const other = groups.find((g) => g.key === OTHER_KEY);
63
+ if (other) {
64
+ const items = other.events
65
+ .slice(-4)
66
+ .map((e) => truncate(e.title, 90));
67
+ for (const it of items)
68
+ lines.push(`- ${it}`);
69
+ }
70
+ return lines.length
71
+ ? lines.join("\n")
72
+ : "_No tracked activity in this window. Likely meetings, planning, or research._";
73
+ }
74
+ function renderToday(openItems) {
75
+ const lines = [];
76
+ const inProgress = openItems.filter((e) => e.kind === "issue_moved");
77
+ const myPRs = openItems.filter((e) => e.kind === "pr_opened");
78
+ const toReview = openItems.filter((e) => e.kind === "pr_review_requested");
79
+ for (const i of inProgress) {
80
+ const label = i.workItem
81
+ ? `${i.workItem} ${i.workItemTitle ?? ""}`.trim()
82
+ : i.title;
83
+ lines.push(`- Continue **${label}**`);
84
+ }
85
+ for (const p of myPRs) {
86
+ const pending = p.signals.includes("review_pending")
87
+ ? " (review pending)"
88
+ : "";
89
+ lines.push(`- Land PR${ref(p)}: ${truncate(stripPrefix(p.title), 80)}${pending}`);
90
+ }
91
+ for (const r of toReview) {
92
+ lines.push(`- Review PR${ref(r)}: ${truncate(stripPrefix(r.title), 80)}`);
93
+ }
94
+ return lines.length
95
+ ? lines.join("\n")
96
+ : "_Nothing queued from your tracked tools. Add your plan here._";
97
+ }
98
+ /** Drop a leading "Opened PR #128: " / "Review PR #130: " label from a title. */
99
+ function stripPrefix(title) {
100
+ return title.replace(/^[^:]*#\d+:\s*/, "").trim() || title;
101
+ }
102
+ function renderBlockers(blockers) {
103
+ if (blockers.length === 0)
104
+ return "_None flagged._";
105
+ return blockers
106
+ .map((b) => {
107
+ const label = b.key ? `${b.key} ${b.title}` : b.title;
108
+ return `- ${SEV_ICON[b.severity]} **${truncate(label, 80)}** · ${b.reasons.join("; ")}`;
109
+ })
110
+ .join("\n");
111
+ }
112
+ export function buildStandupDraft(input) {
113
+ const { actor, window, events, openItems, nowIso, isDemo } = input;
114
+ const groups = groupByWorkItem(events);
115
+ const blockers = detectBlockers(events, openItems, nowIso);
116
+ const out = [];
117
+ out.push(heading(1, `Standup: ${actor.displayName}`));
118
+ out.push(`_${window.label}${isDemo ? ". Demo data, no credentials configured." : ""}_`);
119
+ out.push("");
120
+ out.push(heading(2, "Yesterday"));
121
+ out.push(renderYesterday(groups));
122
+ out.push("");
123
+ out.push(heading(2, "Today"));
124
+ out.push(renderToday(openItems));
125
+ out.push("");
126
+ out.push(heading(2, "Blockers"));
127
+ out.push(renderBlockers(blockers));
128
+ return out.join("\n");
129
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Group activity by work item, not by tool.
3
+ *
4
+ * Reviewers and PMs think in tickets, not in "GitHub said X, Jira said Y". So a
5
+ * commit on branch feature/PROJ-412, the PR that closes PROJ-412, the Jira move
6
+ * of PROJ-412, and the Slack thread about PROJ-412 all collapse into one group.
7
+ * Events with no detectable work item land under "Other".
8
+ */
9
+ export const OTHER_KEY = "__other__";
10
+ export function groupByWorkItem(events) {
11
+ const map = new Map();
12
+ for (const e of events) {
13
+ const key = e.workItem ?? OTHER_KEY;
14
+ const arr = map.get(key);
15
+ if (arr)
16
+ arr.push(e);
17
+ else
18
+ map.set(key, [e]);
19
+ }
20
+ const groups = [];
21
+ for (const [key, evs] of map) {
22
+ evs.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
23
+ const title = evs.find((e) => e.workItemTitle)?.workItemTitle;
24
+ const url = evs.find((e) => e.url && e.workItem === key)?.url;
25
+ const sources = [...new Set(evs.map((e) => e.source))];
26
+ groups.push({
27
+ key,
28
+ title,
29
+ events: evs,
30
+ sources,
31
+ latest: evs[evs.length - 1].timestamp,
32
+ url,
33
+ });
34
+ }
35
+ // Most recently active first; "Other" always last.
36
+ groups.sort((a, b) => {
37
+ if (a.key === OTHER_KEY)
38
+ return 1;
39
+ if (b.key === OTHER_KEY)
40
+ return -1;
41
+ return Date.parse(b.latest) - Date.parse(a.latest);
42
+ });
43
+ return groups;
44
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Weekly summary: a week of activity rolled into accomplishments, for 1:1s and
3
+ * weekly status. Same observed-only rule as the standup; groups work into
4
+ * Shipped vs In progress vs Reviews & support.
5
+ */
6
+ import { groupByWorkItem, OTHER_KEY } from "./grouping.js";
7
+ import { heading, plural } from "../format.js";
8
+ import { truncate } from "../normalize.js";
9
+ const DONE_RE = /\b(done|closed|merged|shipped|resolved|complete|deployed)\b/i;
10
+ function isShipped(group) {
11
+ return group.events.some((e) => e.kind === "pr_merged" ||
12
+ (e.kind === "issue_moved" && !!e.toStatus && DONE_RE.test(e.toStatus)));
13
+ }
14
+ function line(group) {
15
+ const label = group.title ? `${group.key} ${group.title}` : group.key;
16
+ return `- **${truncate(label, 90)}**`;
17
+ }
18
+ export function buildWeeklySummary(events, window, actor, isDemo = false) {
19
+ const groups = groupByWorkItem(events).filter((g) => g.key !== OTHER_KEY);
20
+ const shipped = groups.filter(isShipped);
21
+ const ongoing = groups.filter((g) => !isShipped(g));
22
+ const reviews = events.filter((e) => e.kind === "pr_reviewed");
23
+ const commits = events.filter((e) => e.kind === "commit").length;
24
+ const merged = events.filter((e) => e.kind === "pr_merged").length;
25
+ const out = [];
26
+ out.push(heading(1, `Weekly summary: ${actor.displayName}`));
27
+ out.push(`_${window.label}${isDemo ? ". Demo data." : ""}_`);
28
+ out.push("");
29
+ out.push(heading(2, "Shipped"));
30
+ out.push(shipped.length ? shipped.map(line).join("\n") : "_Nothing closed out this period._");
31
+ out.push("");
32
+ out.push(heading(2, "In progress"));
33
+ out.push(ongoing.length ? ongoing.map(line).join("\n") : "_Nothing open._");
34
+ if (reviews.length) {
35
+ out.push("");
36
+ out.push(heading(2, "Reviews and support"));
37
+ out.push(`- Reviewed ${plural(reviews.length, "PR")} for teammates`);
38
+ }
39
+ out.push("");
40
+ out.push(`_${plural(groups.length, "work item")} touched, ${plural(merged, "PR")} merged, ${plural(commits, "commit")}._`);
41
+ return out.join("\n");
42
+ }