Skip to content
Merged
731 changes: 731 additions & 0 deletions docs/superpowers/plans/2026-06-09-glook-19-inflight-projects.md

Large diffs are not rendered by default.

170 changes: 170 additions & 0 deletions docs/superpowers/specs/2026-06-09-glook-19-inflight-projects-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# GLOOK-19: Add In-Flight Work to Current Projects Cards

## Goal

Surface unmerged PRs and bare-branch commits in the "Current Projects" LLM clustering on both the per-team card (team page) and the org-wide card (home page), so both cards reflect what the team is actively working on — not just what has already shipped.

## Scope

Two independent surfaces share the same conceptual treatment but different code paths:

| Surface | Entry point | Prompt | Cache |
|---|---|---|---|
| Team-page per-team card | `src/lib/team-pulse/projects.ts` | `prompts/team-pulse-projects.txt` | `team_pulse_summaries` (keyed by `prompt_version`) |
| Home-page org-wide card | `src/app/api/project-insights/route.ts` | Inline system prompt | `report_comparisons` (keyed by `report_id_a = report_id_b`) |

The output type (`ProjectsCardItem` / `TeamProject`) and the `ProjectsCard` React component are unchanged.

---

## Section 1: Data Layer

### New Types (`src/lib/team-pulse/data.ts`)

```typescript
export interface TeamProjectInflightPr {
repo: string;
title: string;
author: string;
additions: number;
deletions: number;
is_draft: boolean;
}

export interface TeamProjectInflightBranch {
repo: string;
branch: string;
author: string;
commit_count: number;
lines: number;
}
```

### Extended `TeamProjectsInput`

Add two new fields to the existing interface:

```typescript
export interface TeamProjectsInput {
commits: TeamProjectCommit[];
jira_issues: TeamProjectJiraIssue[];
team_members: string[];
in_flight_prs: TeamProjectInflightPr[]; // new
in_flight_branches: TeamProjectInflightBranch[]; // new
}
```

### `extractTeamProjectsData` (team-page)

Add two SQL queries after the existing commit/Jira fetches:

```sql
-- Top 30 open PRs for team members, ordered by size
SELECT repo, pr_title AS title, github_login AS author,
COALESCE(pr_additions, 0) AS additions,
COALESCE(pr_deletions, 0) AS deletions,
is_draft
FROM unmerged_prs
WHERE report_id = ? AND github_login IN (...)
ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC
LIMIT 30
```

```sql
-- Top 10 bare branches (no PR) aggregated per repo+branch+author
SELECT repo, branch, github_login AS author,
COUNT(*) AS commit_count,
SUM(lines_added + lines_removed) AS lines
FROM unmerged_commits
WHERE report_id = ? AND github_login IN (...) AND pr_number IS NULL
GROUP BY repo, branch, github_login
ORDER BY lines DESC
LIMIT 10
```

`is_draft` normalised to `boolean` (MySQL returns `0`/`1`; SQLite returns same — coerce via `r.is_draft === 1 || r.is_draft === true`).

### Home-page (`src/app/api/project-insights/route.ts`)

Two new inline SQL queries against the latest completed report (no new shared types — route is self-contained):

```sql
SELECT repo, pr_title, github_login, pr_additions, pr_deletions, is_draft
FROM unmerged_prs
WHERE report_id = ?
ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC
LIMIT 30
```

```sql
SELECT repo, branch, github_login,
COUNT(*) AS commit_count,
SUM(lines_added + lines_removed) AS total_lines
FROM unmerged_commits
WHERE report_id = ? AND pr_number IS NULL
GROUP BY repo, branch, github_login
ORDER BY total_lines DESC
LIMIT 10
```

Results rendered into `userMessage` as a pipe-delimited block (see Section 2).

---

## Section 2: Prompt Changes

### In-flight block format

A shared rendering pattern used by both surfaces. When both lists are empty the block is omitted entirely (no heading, no empty section).

```
IN-FLIGHT WORK (open PRs + bare branches — not yet merged):

OPEN PRs (N):
repo|pr_title|author|+additions/-deletions|draft
acme/frontend|Add pagination to jobs table|alice|+340/-12|no
acme/backend|WIP: retry logic for BRZ connector|bob|+89/-4|yes

BARE BRANCHES (N):
repo|branch|author|commits|+lines/-lines
acme/infra|feat/k8s-autoscale|carol|7|+210/-30
```

### Team-page prompt (`prompts/team-pulse-projects.txt`)

Add placeholder `{{IN_FLIGHT_BLOCK}}` at the bottom of the template (after `JIRA ISSUES`).

Add one rule to the RULES section:
> Use IN-FLIGHT WORK to enrich clusters — mix open PRs and bare branches into existing projects where they clearly belong. If in-flight work represents a coherent new thread with no committed counterpart, create a project for it. Draft PRs are included; let their content guide you on whether they belong to an existing project or a new one.

Update `generateTeamProjects` in `src/lib/team-pulse/projects.ts` to render the block and pass it as `IN_FLIGHT_BLOCK` to `loadPrompt`.

### Home-page prompt (`src/app/api/project-insights/route.ts`)

Append the rendered block to `userMessage`. Add the same in-flight instruction to the inline system prompt (one sentence, matching the rule above).

---

## Section 3: Cache Invalidation

**Team-page:** Bump `PROMPT_VERSION` in `src/lib/team-pulse/service.ts` from `'v3-projects'` to `'v4-inflight'`. Old cache rows with `v3-projects` are automatically skipped on the next request.

**Home-page:** Store `_v: 2` in the cached `highlights_json` object. On cache read, if `data._v !== 2` treat as a cache miss and regenerate. New responses are stored with `_v: 2`.

---

## Section 4: Tests

**`src/lib/__tests__/unit/team-projects-data.test.ts`** — new tests:
- `in_flight_prs` populated from `unmerged_prs`, ordered by size, capped at 30
- `in_flight_branches` aggregates bare commits per branch, capped at 10
- Both fields are `[]` when no in-flight rows exist
- `is_draft` coerced to boolean correctly

**`src/lib/__tests__/unit/team-projects-generator.test.ts`** — new tests:
- Rendered prompt includes `IN-FLIGHT WORK` block when in-flight data is present
- Block is omitted when `in_flight_prs` and `in_flight_branches` are both empty

**Snapshot update:** After editing `prompts/team-pulse-projects.txt`, run `npm test -- -u` to update the snapshot in `prompts.test.ts`. Review the diff to confirm the new placeholder and rule are present.

**Home-page:** No new unit tests (consistent with existing coverage posture for this route). Cache `_v` check is straightforward.
2 changes: 2 additions & 0 deletions prompts/team-pulse-projects.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ RULES:
- Each project's `developers` array MUST contain ONLY github logins from {{TEAM_MEMBERS_JSON}}. Reject any other login.
- Drop singleton clusters: every project must have at least 2 commits OR 1 Jira issue.
- `last_activity` MUST be a real ISO-8601 date copied from the most recent committed_at in the cluster.
- Use IN-FLIGHT WORK to enrich clusters — mix open PRs and bare branches into existing projects where they clearly belong. If in-flight work represents a coherent new thread with no committed counterpart, create a project for it. Draft PRs are included.
- Output is a JSON object with a single key "projects" whose value is the array.

OUTPUT JSON SCHEMA (one entry per project):
Expand All @@ -31,3 +32,4 @@ COMMITS (most recent first, capped at 200):

JIRA ISSUES:
{{JIRA_ISSUES_JSON}}
{{IN_FLIGHT_BLOCK}}
81 changes: 69 additions & 12 deletions src/app/api/project-insights/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
import db from '@/lib/db';
import { getLLMClient, LLM_MODEL, extraBodyProps, tokenLimit } from '@/lib/llm-provider';
import { withRequestLog } from '@/lib/logger';
import { renderInflightBlock } from '@/lib/team-pulse/render';
import type { TeamProjectInflightPr, TeamProjectInflightBranch } from '@/lib/team-pulse/data';

async function getHandler() {
// Find latest completed report
Expand Down Expand Up @@ -35,12 +37,18 @@ async function getHandler() {
const data = typeof cached[0].highlights_json === 'string'
? JSON.parse(cached[0].highlights_json)
: cached[0].highlights_json;
return NextResponse.json({
available: true,
report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at },
...data,
cached: true,
});
if (data._v === 2) {
const { _v: _, ...rest } = data;
return NextResponse.json({
available: true,
report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at },
...rest,
cached: true,
});
}
// _v !== 2: stale cache (no in-flight data) — fall through to regenerate.
// Once all active orgs have a _v:2 row this guard becomes pure overhead;
// safe to remove after all legacy rows have naturally expired or been overwritten.
}

// Gather data for LLM
Expand Down Expand Up @@ -76,6 +84,50 @@ async function getHandler() {

const noJiraData = noJiraCommits.map((c: any) => `${c.repo}|${c.github_login}|${c.msg || ''}`).join('\n');

// In-flight: open PRs (top 30 by size)
const [inflightPrRows] = await db.execute(
`SELECT repo, pr_title, github_login, is_draft,
COALESCE(pr_additions, 0) AS pr_additions,
COALESCE(pr_deletions, 0) AS pr_deletions
FROM unmerged_prs
WHERE report_id = ?
ORDER BY COALESCE(pr_additions, 0) + COALESCE(pr_deletions, 0) DESC
LIMIT 30`,
[report.id],
) as [any[], any];

// In-flight: bare branches (top 10 by total lines)
const [inflightBranchRows] = await db.execute(
`SELECT repo, branch, github_login,
COUNT(*) AS commit_count,
SUM(lines_added + lines_removed) AS total_lines
FROM unmerged_commits
WHERE report_id = ? AND pr_number IS NULL
GROUP BY repo, branch, github_login
ORDER BY total_lines DESC
LIMIT 10`,
[report.id],
) as [any[], any];

// Map raw DB rows to the shared typed interface so renderInflightBlock
// handles formatting and length-capping consistently across both surfaces.
const inflightPrs: TeamProjectInflightPr[] = inflightPrRows.map((r: any) => ({
repo: String(r.repo ?? ''),
title: String(r.pr_title ?? ''),
author: String(r.github_login ?? ''),
additions: Number(r.pr_additions ?? 0),
deletions: Number(r.pr_deletions ?? 0),
is_draft: r.is_draft === 1 || r.is_draft === true,
}));
const inflightBranches: TeamProjectInflightBranch[] = inflightBranchRows.map((r: any) => ({
repo: String(r.repo ?? ''),
branch: String(r.branch ?? ''),
author: String(r.github_login ?? ''),
commit_count: Number(r.commit_count ?? 0),
lines: Number(r.total_lines ?? 0),
}));
const inflightBlock = renderInflightBlock(inflightPrs, inflightBranches);

const systemPrompt = `You are an engineering analytics assistant. Analyze Jira issues and GitHub commits from a single report period to identify the top projects the team is working on.

You will receive:
Expand Down Expand Up @@ -118,7 +170,12 @@ Rules:
- A single Jira project might contain multiple distinct projects
- For estimated_commits/prs: if a dev has 50 commits and works on 2 projects with equal issues, attribute ~25 each
- Keep summaries under 20 words
- Return ONLY raw JSON`;
- Return ONLY raw JSON
- If IN-FLIGHT WORK is present at the end of the user message, use it to enrich project identification — treat open PRs and bare branches as signals of ongoing work. Mix them into existing project clusters where they clearly fit, or create a project if in-flight work has no committed counterpart. Draft PRs are included.`;
// Note: the in-flight instruction lives in systemPrompt while the in-flight data
// is in userMessage. This follows the standard system/user role separation for
// instructions vs data. The team-page surface embeds both in the system prompt
// template (team-pulse-projects.txt) — the difference is cosmetic for LLM behaviour.

const userMessage = `JIRA ISSUES (${jiraRows.length} total):
${jiraData}
Expand All @@ -127,7 +184,7 @@ DEVELOPER STATS (login | commits | PRs):
${devData}

GITHUB COMMITS WITHOUT JIRA (top 30 by size):
${noJiraData}`;
${noJiraData}${inflightBlock}`;

try {
const client = await getLLMClient();
Expand All @@ -148,19 +205,19 @@ ${noJiraData}`;
let parsed: any;
try { parsed = JSON.parse(cleaned); } catch { parsed = { projects: [], untracked_work: [] }; }

// Cache result
const toCache = { _v: 2, projects: parsed.projects || [], untracked_work: parsed.untracked_work || [] };
await db.execute(
`INSERT INTO report_comparisons (report_id_a, report_id_b, highlights_json)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE highlights_json = VALUES(highlights_json), generated_at = NOW()`,
[report.id, report.id, JSON.stringify(parsed)],
[report.id, report.id, JSON.stringify(toCache)],
);

return NextResponse.json({
available: true,
report: { id: report.id, org: report.org, periodDays: report.period_days, createdAt: report.created_at },
projects: parsed.projects || [],
untracked_work: parsed.untracked_work || [],
projects: toCache.projects,
untracked_work: toCache.untracked_work,
cached: false,
});
} catch (err) {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/__tests__/unit/__snapshots__/prompts.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ RULES:
- Each project's \`developers\` array MUST contain ONLY github logins from ["alice","bob"]. Reject any other login.
- Drop singleton clusters: every project must have at least 2 commits OR 1 Jira issue.
- \`last_activity\` MUST be a real ISO-8601 date copied from the most recent committed_at in the cluster.
- Use IN-FLIGHT WORK to enrich clusters — mix open PRs and bare branches into existing projects where they clearly belong. If in-flight work represents a coherent new thread with no committed counterpart, create a project for it. Draft PRs are included.
- Output is a JSON object with a single key "projects" whose value is the array.

OUTPUT JSON SCHEMA (one entry per project):
Expand All @@ -34,5 +35,6 @@ COMMITS (most recent first, capped at 200):

JIRA ISSUES:
[]

"
`;
1 change: 1 addition & 0 deletions src/lib/__tests__/unit/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('team-pulse-projects prompt template', () => {
TEAM_MEMBERS_JSON: '["alice","bob"]',
COMMITS_JSON: '[]',
JIRA_ISSUES_JSON: '[]',
IN_FLIGHT_BLOCK: '',
});
expect(out).toMatchSnapshot();
// Verify no leftover {{...}} placeholders
Expand Down
Loading