feat(team-pulse): include in-flight work in AI summary (GLOOK-7)#44
Conversation
Captures the design for extending the Team Pulse AI summary to include in-flight work signals (open PRs, unmerged branches) per team. Key decisions: - Narrative integration only (no new headings, no UI badges). LLM weaves in-flight context into the existing Team Focus and Alerts sections. - Data sourced from existing snapshot tables (unmerged_prs, unmerged_commits) populated by the report runner. No new fetch. - In-progress Jira intentionally out of scope for v1 (only signals already in the report snapshot are used). - Cache invalidation via a new prompt_version column on team_pulse_summaries; old cached summaries are silently bypassed. Co-Authored-By: Claude <noreply@anthropic.com>
8 bite-sized tasks: schema migration, type stub, pure aggregator with tests, SQL fetcher, prompt template edit, prompt builder with tests, cache invalidation via PROMPT_VERSION, local smoke. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Defaults to an empty struct so the existing pipeline keeps compiling. Real aggregation lands in the next commit. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…OOK-7) Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Old cached summaries (prompt_version='v1') are silently bypassed by the new SELECT and regenerate with the in-flight-aware prompt. Co-Authored-By: Claude <noreply@anthropic.com>
msogin
left a comment
There was a problem hiding this comment.
9 inline comments covering correctness, type safety, test coverage, and prompt rendering. All findings are non-blocking except where noted.
| commitRows: InflightCommitRow[], | ||
| now: Date, | ||
| ): Inflight { | ||
| if (prRows.length === 0 && commitRows.length === 0) return EMPTY_INFLIGHT; |
There was a problem hiding this comment.
🟡 warning
aggregateInflight returns EMPTY_INFLIGHT directly by reference when both inputs are empty. The nested arrays (by_author: [], by_repo: []) are the same instances on the module constant — any caller mutating the returned struct would corrupt all future zero-input calls.
Fix — return a spread clone:
if (prRows.length === 0 && commitRows.length === 0) return {
open_prs: { ...EMPTY_INFLIGHT.open_prs, by_author: [], by_repo: [] },
unmerged_branches: { ...EMPTY_INFLIGHT.unmerged_branches },
};Or Object.freeze the constant (deep-freeze needed for nested arrays).
| ]; | ||
| const out = aggregateInflight([], commits, NOW); | ||
| expect(out.unmerged_branches.total_commits).toBe(5); | ||
| expect(out.unmerged_branches.total_branches).toBe(4); |
There was a problem hiding this comment.
🟡 warning
The test passes but doesn't cover the key deduplication behaviour for null-branch commits. The implementation uses branchKeys.add(`${c.repo} ${c.branch ?? ''}`), which means two null-branch commits from the same repo both map to "api " and are deduplicated into one branch slot — not "one per row." The fixture only has one null-branch commit per repo so this edge case goes untested.
Suggested additions:
- Add a comment on the null-branch row:
// branchless commits in the same repo deduplicate to one branch slot - Add a second
{ github_login: 'bob', repo: 'api', branch: null }commit row and asserttotal_branchesstays at4(not5), confirming deduplication.
| }); | ||
| } | ||
|
|
||
| function renderInflightBlock(i: { open_prs: { total: number; draft: number; ready: number; oldest_days: number; lines_added: number; lines_removed: number; by_author: { login: string; count: number }[]; by_repo: { repo: string; count: number }[] }; unmerged_branches: { total_branches: number; total_commits: number } }): string { |
There was a problem hiding this comment.
🔵 suggestion
renderInflightBlock uses a 130-character inline structural type instead of the exported Inflight interface from ./data. This bypasses TypeScript's structural guard: if Inflight gains a new field, this function won't be required to handle it.
Fix:
import type { Inflight } from './data';
// ...
function renderInflightBlock(i: Inflight): string {| [reportId, teamName, org, summary, JSON.stringify(health)], | ||
| `INSERT INTO team_pulse_summaries (report_id, team_name, org, summary_text, health_json, prompt_version) | ||
| VALUES (?, ?, ?, ?, ?, ?) | ||
| ON DUPLICATE KEY UPDATE summary_text = VALUES(summary_text), health_json = VALUES(health_json), prompt_version = VALUES(prompt_version), generated_at = NOW()`, |
There was a problem hiding this comment.
🟣 question
The spec and commit message say "Old v1 rows survive as historical records but never satisfy the new SELECT." However, ON DUPLICATE KEY UPDATE fires on a (report_id, team_name) conflict and overwrites the v1 row — updating prompt_version from v1 to v2-inflight in place. The cache invalidation behaviour is correct (first SELECT misses → regenerates → writes v2), but the historical preservation claim is false.
Two options:
- Accept overwrite semantics (current code, simpler) → update the spec/commit message comment.
- Preserve old rows → change the UNIQUE constraint to
(report_id, team_name, prompt_version)so v1 and v2 rows coexist.
Which was intended?
| ON DUPLICATE KEY UPDATE summary_text = VALUES(summary_text), health_json = VALUES(health_json), generated_at = NOW()`, | ||
| [reportId, teamName, org, summary, JSON.stringify(health)], | ||
| `INSERT INTO team_pulse_summaries (report_id, team_name, org, summary_text, health_json, prompt_version) | ||
| VALUES (?, ?, ?, ?, ?, ?) |
There was a problem hiding this comment.
🟣 question
ON DUPLICATE KEY UPDATE is MySQL-specific syntax. SQLite uses INSERT OR REPLACE or ON CONFLICT DO UPDATE SET. Does the db wrapper (or the SQLite adapter in sqlite.ts) translate this automatically? If not, the cache INSERT will fail silently on SQLite dev environments. The pre-PR INSERT had the same pattern — just confirming this is already handled rather than a new gap.
| const memberPlaceholders = teamMembers.map(() => '?').join(','); | ||
|
|
||
| const [prRows] = await db.execute( | ||
| `SELECT github_login, repo, is_draft, pr_additions, pr_deletions, pr_updated_at |
There was a problem hiding this comment.
🟣 question
The spec's data-shape section references unmerged_prs.author_login and unmerged_commits.author_login, but the SQL here uses github_login. One of these is wrong. Since there are no integration tests covering this path, a column name mismatch would only surface at runtime.
Can you confirm github_login is the actual column name in both unmerged_prs and unmerged_commits? (If so, the spec should be updated to match.)
|
|
||
| PER-MEMBER DATA: | ||
| {{MEMBER_DATA}} | ||
| {{INFLIGHT_BLOCK}} |
There was a problem hiding this comment.
🔵 suggestion
{{MEMBER_DATA}} and {{INFLIGHT_BLOCK}} are on adjacent lines with no blank line between them. When both render with content, the IN-FLIGHT header will immediately follow the last member-data line with no visual separator in the rendered prompt.
Suggested:
{{MEMBER_DATA}}
{{INFLIGHT_BLOCK}}
| const oldest_days = oldest_ms > 0 ? Math.floor(oldest_ms / 86_400_000) : 0; | ||
|
|
||
| const sortDesc = <T extends { count: number }>(items: T[], tieKey: (x: T) => string) => | ||
| items.sort((a, b) => b.count - a.count || tieKey(a).localeCompare(tieKey(b))); |
There was a problem hiding this comment.
🔵 suggestion
Array.prototype.sort mutates the array in place. Safe here because callers always spread before passing ([...prByAuthor], [...prByRepo]), but the helper gives no indication it mutates. A future caller passing a non-copy could be surprised.
Consider sorting inside the helper: [...items].sort(...) — or rename to sortedDesc to signal it returns a value.
| }); | ||
| }); | ||
|
|
||
| function makeData(inflight: Inflight): TeamPulseData { |
There was a problem hiding this comment.
🔵 suggestion
makeData is declared between the two describe blocks rather than at the top of the file. While valid (function declarations are hoisted), it reads as if it belongs only to the second describe. Moving it above both describes (just below const NOW) would improve readability.
- 🟡 EMPTY_INFLIGHT shared-reference risk → replace the module-level
constant with a `emptyInflight()` factory that returns a fresh object
on every call. Removes the silent mutation hazard.
- 🟡 Branch-dedup test gap → previously claimed "one branch slot per
row" while the implementation deduplicates `(repo, branch)` keys. Add
a second null-branch commit in the same repo, assert total_branches
stays at 4 (not 5), and clarify the comment.
- 🔵 renderInflightBlock now takes the exported `Inflight` interface
instead of a 130-char inline structural type. Future fields on
Inflight will be caught at type-check time.
- 🔵 Blank line added between {{MEMBER_DATA}} and {{INFLIGHT_BLOCK}}
in the prompt template so the IN-FLIGHT header isn't visually butted
against the last member-data line.
- 🔵 `sortDesc` renamed to `sortedDesc` and explicitly copies before
sorting; no longer mutates input. Callers' spreads still work, but
the helper no longer pretends to be pure-by-convention.
- 🔵 makeData moved to the top of the test file (just below NOW),
TeamPulseData / Inflight imports consolidated into a single line.
- 🟣 Spec corrected: the SQL uses `github_login` (not `author_login`)
in both `unmerged_prs` and `unmerged_commits`. The spec text now
matches the actual schema.
- 🟣 Spec corrected: `ON DUPLICATE KEY UPDATE` overwrites the old row
in place (the UNIQUE KEY is still `(report_id, team_name)`), so we
do NOT preserve an audit trail of old summaries. The previous claim
was wrong; accept overwrite semantics for simplicity.
- 🟣 Confirmed (no code change): the SQLite wrapper translates
`ON DUPLICATE KEY UPDATE` automatically per CLAUDE.md gotchas
section. Pre-existing pattern, unchanged here.
Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Implements GLOOK-7: extend the Team Pulse AI summary to also describe what a team is currently working on — open PRs and unmerged branches — woven into the existing narrative (no new headings, no UI work).
Spec:
docs/superpowers/specs/2026-05-20-glook-7-team-pulse-inflight-design.mdPlan:
docs/superpowers/plans/2026-05-20-glook-7-team-pulse-inflight.mdWhat the LLM gets now
A new
IN-FLIGHT WORKcontext block, fed byaggregateInflight()over the existingunmerged_prsandunmerged_commitssnapshot tables:Plus one prompt rule instructing the model to weave it into
Team FocusandAlerts(not as a new heading). Smoke-tested on real Integrations data; the model picked it up naturally:Architecture
unmerged_prs,unmerged_commits) that the report runner already populates.aggregateInflight(prRows, commitRows, now)function with 6 unit tests covering empty input, draft/ready counts, top-N with alphabetical tiebreak,oldest_daysmath, and distinct-branch counting (includingbranch IS NULLrows).INFLIGHT_BLOCKplaceholder; renders to either a formatted block or the literal(none)(no{{X}}orphans in the prompt).prompt_versioncolumn onteam_pulse_summaries. Oldv1rows are preserved but silently bypassed by the newv2-inflightlookup; first access after deploy regenerates with the new prompt.Files
src/lib/__tests__/unit/team-pulse-inflight.test.ts— 8 unit tests (aggregator + prompt builder).src/lib/team-pulse/data.ts—Inflight*types,aggregateInflight()pure helper,fetchInflight()SQL helper, wired intoextractTeamPulseData().src/lib/team-pulse/prompt.ts—renderInflightBlock()+INFLIGHT_BLOCKplaceholder.src/lib/team-pulse/service.ts—PROMPT_VERSION = 'v2-inflight'in cache SELECT and INSERT.prompts/team-pulse-system.txt— placeholder + one rule.src/lib/db/mysql.ts,src/lib/db/sqlite.ts—prompt_version VARCHAR(16) NOT NULL DEFAULT 'v1'migration onteam_pulse_summaries.Tests
npm test→ 63 suites / 656 tests pass.npx tsc --noEmit -p tsconfig.json→ clean.129ac0cc…/ Integrations team: cache row written withprompt_version = 'v2-inflight', narrative correctly references in-flight signals.Known caveats (documented in spec)
oldest_daysderived frompr_updated_at(last activity) rather thanpr_created_at(true PR age). Trivial to swap later by changing one column name in the SELECT.Test plan
/report/<id>/team, pick a team filter — TeamPulseCard generates a fresh summary.Activity Changes/Silent Members/Team Focus/Alerts) — noIN-FLIGHTheading.Team Focusand/orAlertsmentions in-flight signals (open PR counts, oldest PR age, large in-flight diffs).SELECT prompt_version FROM team_pulse_summaries WHERE team_name = '<X>'returnsv2-inflightfor fresh rows.IN-FLIGHT WORK (snapshot at report time): (none)and the LLM doesn't invent in-flight content.🤖 Generated with Claude Code