Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,052 linesCodeBlameRaw
1/**
2 * The daily signal: what a workspace's work says, worked out from the
3 * work service's `workspaceSignals` (per repository, added up here) and
4 * billing's usage. Home leads with it (the week's pulse and the findings
5 * worth a look, each with its evidence and where to see it), and the
6 * Workspace overview draws the rest: spend threaded to the work it
7 * produced, what is moving and what is waiting, and quiet changes in who
8 * works where. Every number is worked out here, so each definition is
9 * tested on its own (signals.test.ts) and written down in the Home guide.
10 * Nothing here ranks people or teams against each other or against other
11 * workspaces: a workspace is compared with its own last weeks.
12 * Imports only types.
13 */
14import type {
15 RepoPath,
16 SignalAuthor,
17 SignalDay,
18 SignalHarness,
19 SignalItem,
20 SignalMilestone,
21 SignalPull,
22 SignalWeek,
23 Usage,
24 WaitingPull,
25 WorkspaceSignals,
26} from "@g1t/contracts";
27
28const DAY_MS = 86_400_000;
29
30/** Weeks of history the pulse and the trends read. */
31export const SIGNAL_WEEKS = 10;
32/** Days of history the rhythm of the work draws: last week and this one. */
33export const SIGNAL_DAYS = 14;
34/** How far back pull requests are read: a milestone's build, and the quarter. */
35export const SIGNAL_REACH_DAYS = 120;
36
37// --- Days and weeks ---------------------------------------------------------------
38
39/** `YYYY-MM-DD` of a time, in UTC. */
40export function utcDay(at: number): string {
41 return new Date(at).toISOString().slice(0, 10);
42}
43
44export function addDays(day: string, days: number): string {
45 return utcDay(Date.parse(`${day}T00:00:00Z`) + days * DAY_MS);
46}
47
48/** The first day of each of `weeks` weeks of seven days, the last ending with `until`. */
49export function weekStarts(until: string, weeks = SIGNAL_WEEKS): string[] {
50 return Array.from({ length: weeks }, (_, i) => addDays(until, -7 * (weeks - 1 - i) - 6));
51}
52
53/** Each of `days` days ending with `until`. */
54export function dayList(until: string, days = SIGNAL_DAYS): string[] {
55 return Array.from({ length: days }, (_, i) => addDays(until, i - days + 1));
56}
57
58/** The first day of the quarter `day` is in. */
59export function quarterStart(day: string): string {
60 const month = Number(day.slice(5, 7));
61 const first = month - ((month - 1) % 3);
62 return `${day.slice(0, 4)}-${String(first).padStart(2, "0")}-01`;
63}
64
65/** What the loader asks the work service for, for a page drawn at `now`. */
66export function signalsRange(now: number): { until: string; since: string; weeks: number; days: number; quarter: string } {
67 const until = utcDay(now);
68 const quarter = quarterStart(until);
69 const reach = addDays(until, -SIGNAL_REACH_DAYS);
70 return { until, since: quarter < reach ? quarter : reach, weeks: SIGNAL_WEEKS, days: SIGNAL_DAYS, quarter };
71}
72
73// --- Adding up batches ------------------------------------------------------------
74
75export function emptySignals(): WorkspaceSignals {
76 return { weeks: [], days: [], waiting: [], pulls: [], pullsCapped: false, authors: [], harnesses: [], items: [], milestones: [] };
77}
78
79/** The batches' answers as one: rows are per repository, so they add; items and milestones once each. */
80export function mergeSignals(batches: WorkspaceSignals[]): WorkspaceSignals {
81 const all = emptySignals();
82 const items = new Map<number, SignalItem>();
83 const milestones = new Map<number, SignalMilestone>();
84 for (const batch of batches) {
85 all.weeks.push(...batch.weeks);
86 all.days.push(...batch.days);
87 all.waiting.push(...batch.waiting);
88 all.pulls.push(...batch.pulls);
89 all.authors.push(...batch.authors);
90 all.harnesses.push(...(batch.harnesses ?? []));
91 all.pullsCapped ||= batch.pullsCapped;
92 for (const item of batch.items) items.set(item.number, item);
93 for (const milestone of batch.milestones) milestones.set(milestone.number, milestone);
94 }
95 all.waiting.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
96 all.pulls.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
97 all.items = [...items.values()];
98 all.milestones = [...milestones.values()];
99 return all;
100}
101
102/** One week's measures, every repository's added up. */
103export type WeekTotals = Omit<SignalWeek, "repoId">;
104
105const ZERO_WEEK = (start: string): WeekTotals => ({
106 start,
107 merged: 0,
108 agentMerged: 0,
109 agentReworked: 0,
110 agentDrafted: 0,
111 agentDraftSeconds: 0,
112 reviewed: 0,
113 reviewSeconds: 0,
114 stalled: 0,
115 sessionCostUsd: 0,
116});
117
118/** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */
119export function weekTotals(rows: SignalWeek[], starts: string[], only?: ReadonlySet<string>): WeekTotals[] {
120 const byStart = new Map(starts.map((start) => [start, ZERO_WEEK(start)]));
121 for (const row of rows) {
122 const week = byStart.get(row.start);
123 if (!week || (only && !only.has(row.repoId))) continue;
124 week.merged += row.merged;
125 week.agentMerged += row.agentMerged;
126 week.agentReworked += row.agentReworked;
127 week.agentDrafted += row.agentDrafted;
128 week.agentDraftSeconds += row.agentDraftSeconds;
129 week.reviewed += row.reviewed;
130 week.reviewSeconds += row.reviewSeconds;
131 week.stalled += row.stalled;
132 week.sessionCostUsd += row.sessionCostUsd ?? 0;
133 }
134 return starts.map((start) => byStart.get(start)!);
135}
136
137/**
138 * What the agents' usage came to in each week, from usage's `byDay`
139 * (`YYYY-MM-DD/task`). Null when usage did not answer.
140 */
141export function weeklySpend(usage: Usage | null, starts: string[]): number[] | null {
142 if (!usage) return null;
143 const spend = starts.map(() => 0);
144 for (const slice of usage.byDay) {
145 const day = slice.key.slice(0, 10);
146 // The week a day falls in: the last start on or before it.
147 for (let i = starts.length - 1; i >= 0; i--) {
148 if (day >= starts[i]! && day < addDays(starts[i]!, 7)) {
149 spend[i]! += slice.micros;
150 break;
151 }
152 }
153 }
154 return spend;
155}
156
157// --- Measures -------------------------------------------------------------------
158
159/** Seconds a first review waited, on average, or null with none given. */
160export function reviewWait(week: WeekTotals): number | null {
161 return week.reviewed > 0 ? week.reviewSeconds / week.reviewed : null;
162}
163
164/** Seconds from an agent's session starting to its pull request opening, on average. */
165export function draftTime(week: WeekTotals): number | null {
166 return week.agentDrafted > 0 ? week.agentDraftSeconds / week.agentDrafted : null;
167}
168
169/** The share of an agent's merged pull requests a person asked for changes on, 0 to 1. */
170export function reworkRate(week: WeekTotals): number | null {
171 return week.agentMerged > 0 ? week.agentReworked / week.agentMerged : null;
172}
173
174/** The share of merges that were an agent's, 0 to 1. */
175export function agentShare(week: WeekTotals): number | null {
176 return week.merged > 0 ? week.agentMerged / week.merged : null;
177}
178
179/** Usage per agent merge, in micros. */
180export function spendPerMerge(micros: number | null, week: WeekTotals): number | null {
181 return micros != null && week.agentMerged > 0 ? micros / week.agentMerged : null;
182}
183
184// --- Words ------------------------------------------------------------------------
185
186const WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
187
188/** A count as a sentence starts it: "Three", or "12". */
189export function countWord(n: number): string {
190 return WORDS[n] ?? String(n);
191}
192
193/** A wait in words: "48 minutes", "5 hours", "2.1 days". */
194export function spoken(seconds: number): string {
195 if (seconds < 90) return "a minute";
196 if (seconds < 3600) return `${Math.round(seconds / 60)} minutes`;
197 if (seconds < 1.5 * 3600) return "an hour";
198 if (seconds < 20 * 3600) return `${Math.round(seconds / 3600)} hours`;
199 if (seconds < 36 * 3600) return "a day";
200 const days = seconds / 86400;
201 return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
202}
203
204/** A wait in a few characters, for figures: "48m", "5h", "2.1d". */
205export function short(seconds: number | null): string {
206 if (seconds == null) return "—";
207 if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
208 if (seconds < 86400) return `${(seconds / 3600).toFixed(seconds < 36_000 ? 1 : 0).replace(/\.0$/, "")}h`;
209 return `${(seconds / 86400).toFixed(1).replace(/\.0$/, "")}d`;
210}
211
212export function percent(share: number | null): string {
213 return share == null ? "—" : `${Math.round(share * 100)}%`;
214}
215
216/** Money in micros as the pulse shows it: whole dollars, compact from $10K. */
217export function dollars(micros: number | null): string {
218 if (micros == null) return "—";
219 const value = micros / 1_000_000;
220 if (value >= 10_000) return `$${(value / 1000).toFixed(1).replace(/\.0$/, "")}K`;
221 if (value >= 10) return `$${Math.round(value).toLocaleString("en-US")}`;
222 return `$${value.toFixed(2)}`;
223}
224
225/** The day a page is drawn on, as the daily signal heads it: "Sunday, October 18". */
226export function signalDate(now: number, tz: string | null): string {
227 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "long", day: "numeric" };
228 try {
229 return new Date(now).toLocaleDateString("en-US", { ...options, timeZone: tz ?? "UTC" });
230 } catch {
231 return new Date(now).toLocaleDateString("en-US", { ...options, timeZone: "UTC" });
232 }
233}
234
235/** A week's first day as a trend's point names it: "Oct 12". */
236export function weekLabel(start: string): string {
237 return new Date(`${start}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
238}
239
240// --- The pulse --------------------------------------------------------------------
241
242export type Tone = "good" | "bad" | "flat";
243
244export type PulseTile = {
245 key: "merges" | "spend" | "stalled" | "rework";
246 label: string;
247 /** This week's figure. */
248 value: string;
249 /** Against last week: the difference as a chip says it, and whether that is good news. */
250 delta: { text: string; tone: Tone } | null;
251 /** Each week's figure, oldest first; null for a week with nothing to measure. */
252 series: (number | null)[];
253 /** Each week's point, as its hover says it. */
254 points: string[];
255 /** What it counts, in a sentence. */
256 hint: string;
257 to: string;
258};
259
260function tone(change: number, upIsGood: boolean): Tone {
261 if (change === 0) return "flat";
262 return change > 0 === upIsGood ? "good" : "bad";
263}
264
265function signed(n: number, format: (abs: number) => string): string {
266 return `${n > 0 ? "+" : n < 0 ? "−" : "±"}${format(Math.abs(n))}`;
267}
268
269function deltaOf(now: number | null, before: number | null, format: (abs: number) => string, upIsGood: boolean) {
270 if (now == null || before == null) return null;
271 const change = now - before;
272 return { text: signed(change, format), tone: tone(change, upIsGood) };
273}
274
275export type PulseInput = {
276 slug: string;
277 weeks: WeekTotals[];
278 /** Usage per week, aligned with `weeks`; null when usage did not answer. */
279 spend: number[] | null;
280};
281
282/**
283 * The past week in four figures, each against the week before and drawn
284 * over the last ten: agent merges, usage per agent merge, stalled changes,
285 * and how often people asked for changes on an agent's work.
286 */
287export function pulse({ slug, weeks, spend }: PulseInput): PulseTile[] {
288 const at = weeks.length - 1;
289 const now = weeks[at];
290 const before = weeks[at - 1] ?? null;
291 if (!now) return [];
292 const labels = weeks.map((week) => `Week of ${weekLabel(week.start)}`);
293 const perMerge = weeks.map((week, i) => spendPerMerge(spend?.[i] ?? null, week));
294 const rework = weeks.map(reworkRate);
295 const count = (n: number) => String(Math.round(n));
296 return [
297 {
298 key: "merges",
299 label: "Agent-authored merges",
300 value: String(now.agentMerged),
301 delta: deltaOf(now.agentMerged, before?.agentMerged ?? null, count, true),
302 series: weeks.map((week) => week.agentMerged),
303 points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} of ${week.merged} merges`),
304 hint: "Merged pull requests an agent made: g1t's agent, or a harness session tied to it",
305 to: `/${slug}/-/overview?tab=landed`,
306 },
307 {
308 key: "spend",
309 label: "Spend per agent merge",
310 value: dollars(perMerge[at] ?? null),
311 delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false),
312 series: perMerge,
313 points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent merges" : dollars(micros)}`),
314 hint: "g1t's agent usage at price and harness sessions' cost at list price, over the agent merges",
315 to: `/${slug}/-/usage`,
316 },
317 {
318 key: "stalled",
319 label: "Stalled changes",
320 value: String(now.stalled),
321 delta: deltaOf(now.stalled, before?.stalled ?? null, count, false),
322 series: weeks.map((week) => week.stalled),
323 points: weeks.map((week, i) => `${labels[i]}: ${week.stalled} with nothing for three days`),
324 hint: "Open pull requests with no push, comment, review, check or session for three days",
325 to: `/${slug}/-/overview?tab=needs#work`,
326 },
327 {
328 key: "rework",
329 label: "Agent rework rate",
330 value: percent(rework[at] ?? null),
331 delta: deltaOf(rework[at] ?? null, before ? (rework[at - 1] ?? null) : null, (abs) => `${(abs * 100).toFixed(1).replace(/\.0$/, "")} pts`, false),
332 series: rework,
333 points: rework.map((share, i) => `${labels[i]}: ${share == null ? "no agent merges" : percent(share)}`),
334 hint: "Of the agent pull requests that merged, the share a person asked for changes on first",
335 to: `/${slug}/-/insights`,
336 },
337 ];
338}
339
340// --- The findings -----------------------------------------------------------------
341
342export type Signal = {
343 key: string;
344 /** The finding, in one sentence. */
345 headline: string;
346 /** The evidence, as short facts: "48 min to draft", "2.1 days to first review". */
347 facts: string[];
348 link: { label: string; to: string } | null;
349 /** A measure over the weeks behind the finding, when it is a trend. */
350 trend?: { values: (number | null)[]; points: string[]; caption: string; range: string };
351 /** How much it matters today: findings are shown highest first. */
352 weight: number;
353 /** What Ask is asked, to look into it. */
354 ask: string;
355};
356
357export type SignalContext = {
358 slug: string;
359 now: number;
360 repos: Record<string, RepoPath>;
361 signals: WorkspaceSignals;
362 weeks: WeekTotals[];
363 /** The quarter's usage (by pull request and project); null when usage did not answer. */
364 quarterUsage: Usage | null;
365 quarter: string;
366};
367
368export function pullPath(repo: RepoPath, number: number): string {
369 return `/${repo.namespace}/${repo.name}/pull/${number}`;
370}
371
372export function itemPath(slug: string, number: number): string {
373 return `/${slug}/-/work/${number}`;
374}
375
376export function milestonePath(slug: string, number: number): string {
377 return `/${slug}/-/work/milestones/${number}`;
378}
379
380function ago(now: number, at: string): number {
381 return Math.max(0, (now - Date.parse(at)) / 1000);
382}
383
384/** Several work items wait on one change no one has reviewed: the one holding up the most. */
385export function blockedSignal(ctx: SignalContext): Signal | null {
386 const top = ctx.signals.waiting
387 .filter((pull) => pull.blocks.length > 0 && ctx.repos[pull.repoId])
388 .sort((a, b) => b.blocks.length - a.blocks.length || a.createdAt.localeCompare(b.createdAt))[0];
389 if (!top) return null;
390 const repo = ctx.repos[top.repoId]!;
391 const n = top.blocks.length;
392 const shown = top.blocks.slice(0, 3).map((number) => `#${number}`);
393 return {
394 key: "blocked",
395 headline: n === 1 ? "A work item is waiting on a change no one has reviewed." : `${countWord(n)} work items are waiting on one unreviewed change.`,
396 facts: [
397 `${repo.name}#${top.number} ${top.title}`,
398 `open ${spoken(ago(ctx.now, top.createdAt))}`,
399 `holds up ${shown.join(", ")}${n > 3 ? ` and ${n - 3} more` : ""}`,
400 ],
401 link: { label: "See what's blocked", to: pullPath(repo, top.number) },
402 weight: 60 + 8 * n,
403 ask: `Why is ${repo.namespace}/${repo.name}#${top.number} still waiting for review, and what is waiting on it?`,
404 };
405}
406
407/**
408 * Agents draft quickly and the change then waits for a person: this
409 * week's first reviews against how long the agents took to open them,
410 * with first-review time over the weeks. Or, without that gap, first
411 * reviews that slowed against the weeks before.
412 */
413export function reviewSignal(ctx: SignalContext): Signal | null {
414 const weeks = ctx.weeks;
415 const now = weeks[weeks.length - 1];
416 if (!now) return null;
417 const wait = reviewWait(now);
418 if (wait == null) return null;
419 const draft = draftTime(now);
420 const series = weeks.map(reviewWait);
421 const earlier = series.slice(0, -1).filter((value): value is number => value != null);
422 const usual = earlier.length ? earlier.reduce((sum, value) => sum + value, 0) / earlier.length : null;
423 const first = series.find((value) => value != null) ?? null;
424 const trend = {
425 values: series,
426 points: series.map((value, i) => `Week of ${weekLabel(weeks[i]!.start)}: ${value == null ? "no first reviews" : short(value)}`),
427 caption: "Time to first review",
428 range: `${short(first)} → ${short(wait)} over ${weeks.length} weeks`,
429 };
430 const link = { label: "See the review queue", to: `/${ctx.slug}/-/overview?tab=needs#work` };
431 if (draft != null && wait >= 4 * draft && wait >= 4 * 3600) {
432 return {
433 key: "review",
434 headline: `Changes an agent drafts in ${draft < 3600 ? "under an hour" : spoken(draft)} are waiting ${spoken(wait)} for first review.`,
435 facts: [`${short(draft)} to draft`, `${short(wait)} to first review`, "this week"],
436 link,
437 trend,
438 weight: 40 + Math.min(40, (wait / Math.max(draft, 60)) * 2),
439 ask: "Which agent pull requests are waiting longest for a first review, and who could review them?",
440 };
441 }
442 if (usual != null && wait >= 1.5 * usual && wait - usual >= 2 * 3600) {
443 return {
444 key: "review",
445 headline: `First reviews are taking ${spoken(wait)}, up from ${spoken(usual)} in the weeks before.`,
446 facts: [`${now.reviewed} first reviews this week`, `${short(wait)} on average`],
447 link,
448 trend,
449 weight: 30 + Math.min(30, (wait / usual) * 5),
450 ask: "Why are first reviews slower this week than usual?",
451 };
452 }
453 return null;
454}
455
456const MICROS = 1_000_000;
457
458/** Whether a pull request's work falls in the quarter: opened in it, or merged in it. */
459function inQuarter(pull: SignalPull, quarter: string): boolean {
460 return pull.createdAt.slice(0, 10) >= quarter || (pull.mergedAt != null && pull.mergedAt.slice(0, 10) >= quarter);
461}
462
463/**
464 * What the quarter's agent work cost, by pull request (`namespace/name#number`)
465 * and in all: g1t's metered usage on each pull request (usage's `byPull`), and
466 * each pull request's share of what its harness sessions cost, estimated at
467 * list price from their token usage. The total also holds g1t usage on pull
468 * requests the reading doesn't list. Null when usage did not answer.
469 */
470export function quarterSpend(
471 ctx: Pick<SignalContext, "repos" | "signals" | "quarterUsage" | "quarter">,
472): { byPull: Map<string, number>; total: number; sessions: number } | null {
473 if (!ctx.quarterUsage) return null;
474 const byPull = new Map<string, number>();
475 let total = 0;
476 for (const slice of ctx.quarterUsage.byPull) {
477 const key = slice.key.toLowerCase();
478 byPull.set(key, (byPull.get(key) ?? 0) + slice.micros);
479 total += slice.micros;
480 }
481 let sessions = 0;
482 for (const pull of ctx.signals.pulls) {
483 const repo = ctx.repos[pull.repoId];
484 const micros = Math.round((pull.sessionCostUsd ?? 0) * MICROS);
485 if (!repo || micros <= 0 || !inQuarter(pull, ctx.quarter)) continue;
486 const key = pullKey(repo, pull.number);
487 byPull.set(key, (byPull.get(key) ?? 0) + micros);
488 total += micros;
489 sessions += micros;
490 }
491 return { byPull, total, sessions };
492}
493
494function pullKey(repo: RepoPath, number: number): string {
495 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
496}
497
498/** Where the quarter's agent usage went, by milestone (else work item, else project): the biggest share. */
499export function spendSignal(ctx: SignalContext): Signal | null {
500 const quarter = quarterSpend(ctx);
501 if (!quarter || quarter.total <= 0) return null;
502 const { byPull: spend, total } = quarter;
503 const items = new Map(ctx.signals.items.map((item) => [item.number, item]));
504 const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone]));
505 const groups = new Map<string, { label: string; to: string; micros: number }>();
506 for (const pull of ctx.signals.pulls) {
507 const repo = ctx.repos[pull.repoId];
508 if (!repo) continue;
509 const micros = spend.get(pullKey(repo, pull.number));
510 if (!micros) continue;
511 const milestone = pull.milestone != null ? milestones.get(pull.milestone) : undefined;
512 const item = pull.item != null ? items.get(pull.item) : undefined;
513 const group = milestone
514 ? { key: `m${milestone.number}`, label: milestone.title, to: milestonePath(ctx.slug, milestone.number) }
515 : item
516 ? { key: `i${item.number}`, label: item.title, to: itemPath(ctx.slug, item.number) }
517 : { key: `r${pull.repoId}`, label: repo.name, to: `/${repo.namespace}/${repo.name}` };
518 const entry = groups.get(group.key) ?? { label: group.label, to: group.to, micros: 0 };
519 entry.micros += micros;
520 groups.set(group.key, entry);
521 }
522 const top = [...groups.values()].sort((a, b) => b.micros - a.micros)[0];
523 if (!top) return null;
524 const share = top.micros / total;
525 if (share < 0.35 || groups.size < 2) return null;
526 const amount = share > 0.5 ? "More than half" : share > 1 / 3 ? "More than a third" : `${Math.round(share * 100)}%`;
527 return {
528 key: "spend",
529 headline: `${amount} of this quarter's agent spend is going into ${top.label}.`,
530 facts: [`${dollars(top.micros)} of ${dollars(total)}`, "quarter to date"],
531 link: { label: "See the spend", to: `/${ctx.slug}/-/workspace#spend` },
532 weight: 25 + share * 40,
533 ask: `What has the agent spend on ${top.label} produced this quarter?`,
534 };
535}
536
537const WEEK_MS = 7 * DAY_MS;
538
539/** A milestone closed in the last week, against its due date, with what it took. */
540export function shippedSignal(ctx: SignalContext): Signal | null {
541 const recent = ctx.signals.milestones
542 .filter((milestone) => milestone.state === "closed" && milestone.closedAt && ctx.now - Date.parse(milestone.closedAt) <= WEEK_MS)
543 .sort((a, b) => b.closedAt!.localeCompare(a.closedAt!))[0];
544 if (!recent) return null;
545 const closed = Date.parse(recent.closedAt!);
546 const pulls = ctx.signals.pulls.filter((pull) => pull.milestone === recent.number && pull.status === "merged");
547 const started = Math.min(Date.parse(recent.createdAt), ...pulls.map((pull) => Date.parse(pull.createdAt)));
548 const weeksIn = Math.max(1, Math.round((closed - started) / WEEK_MS));
549 const projects = new Set(pulls.map((pull) => pull.repoId)).size;
550 const weekday = new Date(closed).toLocaleDateString("en-US", { weekday: "long", timeZone: "UTC" });
551 let schedule = "";
552 let weight = 35;
553 if (recent.dueOn) {
554 const early = Math.round((Date.parse(`${recent.dueOn}T23:59:59Z`) - closed) / DAY_MS);
555 if (early >= 14) {
556 schedule = `, ${countWord(Math.round(early / 7)).toLowerCase()} weeks ahead of schedule`;
557 weight = 45;
558 } else if (early >= 2) schedule = `, ${early} days ahead of schedule`;
559 else if (early >= 0) schedule = ", on schedule";
560 else schedule = `, ${spoken(-early * 86400)} late`;
561 }
562 return {
563 key: "shipped",
564 headline: `${recent.title} shipped on ${weekday}${schedule}.`,
565 facts: [
566 `${weeksIn} ${weeksIn === 1 ? "week" : "weeks"} in build`,
567 `${pulls.length} ${pulls.length === 1 ? "change" : "changes"}`,
568 ...(projects > 0 ? [`${projects} ${projects === 1 ? "project" : "projects"}`] : []),
569 ],
570 link: { label: "See the work", to: milestonePath(ctx.slug, recent.number) },
571 weight,
572 ask: `Summarize what shipped in the milestone ${recent.title}.`,
573 };
574}
575
576/** More changes stalled than last week. */
577export function stalledSignal(ctx: SignalContext): Signal | null {
578 const now = ctx.weeks[ctx.weeks.length - 1];
579 const before = ctx.weeks[ctx.weeks.length - 2];
580 if (!now || !before) return null;
581 const more = now.stalled - before.stalled;
582 if (more < 3 || now.stalled < 1.25 * before.stalled) return null;
583 return {
584 key: "stalled",
585 headline: `${countWord(now.stalled)} open changes have gone three days with nothing happening, ${more} more than last week.`,
586 facts: [`${before.stalled} last week`, `${now.stalled} now`],
587 link: { label: "See what's stalled", to: `/${ctx.slug}/-/overview?tab=needs#work` },
588 trend: {
589 values: ctx.weeks.map((week) => week.stalled),
590 points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`),
591 caption: "Stalled changes",
592 range: `${ctx.weeks[0]!.stalled} → ${now.stalled} over ${ctx.weeks.length} weeks`,
593 },
594 weight: 30 + Math.min(25, more * 2),
595 ask: "Which pull requests have stalled this week, and what are they waiting on?",
596 };
597}
598
599/** People asking for changes on agents' work more often, or less, than the weeks before. */
600export function reworkSignal(ctx: SignalContext): Signal | null {
601 const now = ctx.weeks[ctx.weeks.length - 1];
602 if (!now || now.agentMerged < 5) return null;
603 const rate = reworkRate(now)!;
604 const earlier = ctx.weeks.slice(-5, -1);
605 const merged = earlier.reduce((sum, week) => sum + week.agentMerged, 0);
606 if (merged < 5) return null;
607 const usual = earlier.reduce((sum, week) => sum + week.agentReworked, 0) / merged;
608 const change = rate - usual;
609 if (Math.abs(change) < 0.08) return null;
610 const up = change > 0;
611 return {
612 key: "rework",
613 headline: up
614 ? `People asked for changes on ${percent(rate)} of agents' merged work this week, up from ${percent(usual)}.`
615 : `Agents' work is landing with fewer requests for changes: ${percent(rate)} this week, down from ${percent(usual)}.`,
616 facts: [`${now.agentReworked} of ${now.agentMerged} agent merges reworked`, "against the four weeks before"],
617 link: { label: "See Insights", to: `/${ctx.slug}/-/insights` },
618 weight: up ? 30 + change * 100 : 15 + Math.abs(change) * 60,
619 ask: up ? "What did reviewers ask agents to change this week?" : "What changed in how agents' work is reviewed this week?",
620 };
621}
622
623// --- Who works where --------------------------------------------------------------
624
625/** People who never merged to a project before, now have: in the last `days`, with merges elsewhere first. */
626export type NewGround = { ownerId: string; name: string; repoId: string; firstMergedAt: string; elsewhere: number };
627
628export function newGround(authors: SignalAuthor[], now: number, days = 14): NewGround[] {
629 const byOwner = new Map<string, SignalAuthor[]>();
630 for (const row of authors) byOwner.set(row.ownerId, [...(byOwner.get(row.ownerId) ?? []), row]);
631 const found: NewGround[] = [];
632 for (const rows of byOwner.values()) {
633 for (const row of rows) {
634 if (now - Date.parse(row.firstMergedAt) > days * DAY_MS) continue;
635 // Merges elsewhere before this one: someone new to the project, not new to the workspace.
636 const elsewhere = rows
637 .filter((other) => other.repoId !== row.repoId && other.firstMergedAt < row.firstMergedAt)
638 .reduce((sum, other) => sum + other.merged, 0);
639 if (elsewhere >= 3) found.push({ ownerId: row.ownerId, name: row.ownerName, repoId: row.repoId, firstMergedAt: row.firstMergedAt, elsewhere });
640 }
641 }
642 return found.sort((a, b) => b.firstMergedAt.localeCompare(a.firstMergedAt));
643}
644
645/**
646 * A project where one person has done most of the recent work: their
647 * share of its merges in the last 90 days, and whoever else has started
648 * merging there in the last 30 (knowledge spreading, or not yet).
649 */
650export type Silo = {
651 repoId: string;
652 ownerId: string;
653 name: string;
654 share: number;
655 theirs: number;
656 recent: number;
657 joining: { ownerId: string; name: string }[];
658};
659
660export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75 } = {}): Silo[] {
661 const byRepo = new Map<string, SignalAuthor[]>();
662 for (const row of authors) byRepo.set(row.repoId, [...(byRepo.get(row.repoId) ?? []), row]);
663 const found: Silo[] = [];
664 for (const [repoId, rows] of byRepo) {
665 const recent = rows.reduce((sum, row) => sum + row.recent, 0);
666 if (recent < minRecent) continue;
667 const top = [...rows].sort((a, b) => b.recent - a.recent)[0]!;
668 const share = top.recent / recent;
669 if (share < minShare) continue;
670 const joining = rows
671 .filter((row) => row.ownerId !== top.ownerId && row.recent > 0 && now - Date.parse(row.firstMergedAt) <= 30 * DAY_MS)
672 .map((row) => ({ ownerId: row.ownerId, name: row.ownerName }));
673 found.push({ repoId, ownerId: top.ownerId, name: top.ownerName, share, theirs: top.recent, recent, joining });
674 }
675 return found.sort((a, b) => b.recent * b.share - a.recent * a.share);
676}
677
678/** People whose first merged agent pull request came in the last `days`, after merging their own work before. */
679export type NewHabit = { ownerId: string; name: string; firstAgentAt: string; repoId: string; byHand: number };
680
681export function newHabits(pulls: SignalPull[], now: number, days = 14): NewHabit[] {
682 const byOwner = new Map<string, SignalPull[]>();
683 for (const pull of pulls) if (pull.status === "merged" && pull.mergedAt) byOwner.set(pull.ownerId, [...(byOwner.get(pull.ownerId) ?? []), pull]);
684 const found: NewHabit[] = [];
685 for (const merged of byOwner.values()) {
686 merged.sort((a, b) => a.mergedAt!.localeCompare(b.mergedAt!));
687 const first = merged.find((pull) => pull.agent);
688 if (!first || now - Date.parse(first.mergedAt!) > days * DAY_MS) continue;
689 const byHand = merged.filter((pull) => !pull.agent && pull.mergedAt! < first.mergedAt!).length;
690 if (byHand >= 3) found.push({ ownerId: first.ownerId, name: first.ownerName, firstAgentAt: first.mergedAt!, repoId: first.repoId, byHand });
691 }
692 return found.sort((a, b) => b.firstAgentAt.localeCompare(a.firstAgentAt));
693}
694
695/**
696 * A way of working catching on: a harness that someone took up first,
697 * then at least two more people started using in the last `days`, at
698 * least two weeks after them. Over the repositories in `only` (a team's),
699 * or all of them; the newest spread first.
700 */
701export type Spread = {
702 harness: string;
703 first: { ownerId: string; name: string; at: string };
704 recent: { ownerId: string; name: string; at: string }[];
705 people: number;
706};
707
708export function spreading(harnesses: SignalHarness[], now: number, { days = 30, only }: { days?: number; only?: ReadonlySet<string> } = {}): Spread[] {
709 // Each person's first session with each harness, over the repositories asked about.
710 const firsts = new Map<string, Map<string, { ownerId: string; name: string; at: string }>>();
711 for (const row of harnesses) {
712 if (only && !only.has(row.repoId)) continue;
713 const people = firsts.get(row.harness) ?? new Map();
714 const known = people.get(row.ownerId);
715 if (!known || row.firstAt < known.at) people.set(row.ownerId, { ownerId: row.ownerId, name: row.ownerName, at: row.firstAt });
716 firsts.set(row.harness, people);
717 }
718 const found: Spread[] = [];
719 for (const [harness, people] of firsts) {
720 const order = [...people.values()].sort((a, b) => a.at.localeCompare(b.at));
721 const first = order[0]!;
722 const recent = order.filter((person) => now - Date.parse(person.at) <= days * DAY_MS && Date.parse(person.at) - Date.parse(first.at) >= 14 * DAY_MS);
723 if (recent.length >= 2) found.push({ harness, first, recent, people: order.length });
724 }
725 return found.sort((a, b) => b.recent[b.recent.length - 1]!.at.localeCompare(a.recent[a.recent.length - 1]!.at));
726}
727
728const HARNESS_WORDS: Record<string, string> = {
729 claude_code: "Claude Code",
730 claude_managed_agents: "Claude Managed Agents",
731 codex: "Codex",
732 cursor: "Cursor",
733 copilot: "Copilot",
734};
735
736/** A harness as a sentence names it. */
737export function harnessWords(harness: string): string {
738 return HARNESS_WORDS[harness] ?? "another harness";
739}
740
741/** Names in a sentence: "ana", "ana and bo", "ana, bo and 2 others". */
742export function namesWords(names: string[], max = 3): string {
743 if (names.length <= 1) return names[0] ?? "";
744 if (names.length <= max) return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
745 return `${names.slice(0, max - 1).join(", ")} and ${names.length - max + 1} others`;
746}
747
748/** A practice catching on, as a finding. */
749export function spreadSignal(ctx: SignalContext): Signal | null {
750 const spread = spreading(ctx.signals.harnesses ?? [], ctx.now)[0];
751 if (!spread) return null;
752 const tool = harnessWords(spread.harness);
753 return {
754 key: "spread",
755 headline: `${tool} is catching on: ${namesWords(spread.recent.map((person) => person.name))} started using it this month, after ${spread.first.name}.`,
756 facts: [
757 `${spread.people} people use it now`,
758 `${spread.first.name} first, ${spoken(Math.max(0, (Date.parse(spread.recent[0]!.at) - Date.parse(spread.first.at)) / 1000))} before the next`,
759 ],
760 link: { label: "See the sessions", to: `/${ctx.slug}/-/work/sessions` },
761 weight: 22 + Math.min(12, spread.recent.length * 3),
762 ask: `How have ${namesWords(spread.recent.map((person) => person.name))} been using ${tool}, and what did ${spread.first.name} do with it first?`,
763 };
764}
765
766/** The quiet changes as one finding: someone moving into a project one person has carried, else anyone new to one. */
767export function groundSignal(ctx: SignalContext): Signal | null {
768 const spreading = silos(ctx.signals.authors, ctx.now).find((silo) => silo.joining.length > 0 && ctx.repos[silo.repoId]);
769 if (spreading) {
770 const repo = ctx.repos[spreading.repoId]!;
771 const joiner = spreading.joining[0]!;
772 return {
773 key: "ground",
774 headline: `${joiner.name} is starting to work in ${repo.name}, which ${spreading.name} has carried almost alone.`,
775 facts: [`${spreading.theirs} of ${spreading.recent} merges in 90 days were ${spreading.name}'s`, `${joiner.name} started this month`],
776 link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` },
777 weight: 28,
778 ask: `What has ${joiner.name} worked on in ${repo.namespace}/${repo.name} so far?`,
779 };
780 }
781 const moved = newGround(ctx.signals.authors, ctx.now, 7).filter((row) => ctx.repos[row.repoId]);
782 if (moved.length === 0) return null;
783 const first = moved[0]!;
784 const repo = ctx.repos[first.repoId]!;
785 const others = new Set(moved.map((row) => row.ownerId)).size - 1;
786 return {
787 key: "ground",
788 headline:
789 others > 0
790 ? `${first.name} and ${others === 1 ? "one other person" : `${others} others`} merged their first change to a project new to them this week.`
791 : `${first.name} merged a first change to ${repo.name} this week, new ground for them.`,
792 facts: [`${first.elsewhere} merges elsewhere before`, `first in ${repo.name}`],
793 link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` },
794 weight: 20 + Math.min(10, others * 3),
795 ask: `What did ${first.name} change in ${repo.namespace}/${repo.name}?`,
796 };
797}
798
799const FINDINGS = [blockedSignal, reviewSignal, shippedSignal, spendSignal, stalledSignal, reworkSignal, groundSignal, spreadSignal];
800
801/** Every finding worth a look today, the one that matters most first. A finding that cannot be worked out is left out. */
802export function findings(ctx: SignalContext): Signal[] {
803 const found: Signal[] = [];
804 for (const make of FINDINGS) {
805 try {
806 const signal = make(ctx);
807 if (signal) found.push(signal);
808 } catch {
809 // One finding's arithmetic never takes the rest down.
810 }
811 }
812 return found.sort((a, b) => b.weight - a.weight);
813}
814
815/** The headline when nothing stands out: still a sentence about the week. */
816export function quietHeadline(week: WeekTotals | undefined): string {
817 if (!week || week.merged === 0) return "A quiet week: nothing has merged yet, and nothing is stuck.";
818 const share = agentShare(week);
819 return `${countWord(week.merged)} ${week.merged === 1 ? "change" : "changes"} merged this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`;
820}
821
822/** What Home shows: the pulse, and the findings with the one that leads. */
823export type DailySignal = {
824 tiles: PulseTile[];
825 findings: Signal[];
826 /** The headline when no finding stands out. */
827 quiet: string;
828 complete: boolean;
829};
830
831export function dailySignal(data: {
832 slug: string;
833 now: number;
834 repos: Record<string, RepoPath>;
835 signals: WorkspaceSignals;
836 complete: boolean;
837 weeks: WeekTotals[];
838 spend: number[] | null;
839 quarterUsage: Usage | null;
840 range: { quarter: string };
841}): DailySignal {
842 return {
843 tiles: pulse({ slug: data.slug, weeks: data.weeks, spend: data.spend }),
844 findings: findings({
845 slug: data.slug,
846 now: data.now,
847 repos: data.repos,
848 signals: data.signals,
849 weeks: data.weeks,
850 quarterUsage: data.quarterUsage,
851 quarter: data.range.quarter,
852 }),
853 quiet: quietHeadline(data.weeks[data.weeks.length - 1]),
854 complete: data.complete,
855 };
856}
857
858// --- Spend threaded to the work ---------------------------------------------------
859
860/** How much of a node's work was an agent's: 0 (none) to 4 (nearly all), the flow's five steps. */
861export function agentStep(share: number): 0 | 1 | 2 | 3 | 4 {
862 if (share < 0.2) return 0;
863 if (share < 0.4) return 1;
864 if (share < 0.6) return 2;
865 if (share < 0.8) return 3;
866 return 4;
867}
868
869export type FlowPull = { key: string; repo: string; number: number; title: string; agent: boolean; micros: number; status: SignalPull["status"]; to: string };
870
871export type FlowNode = {
872 key: string;
873 label: string;
874 /** Where its own page is, when it has one. */
875 to: string | null;
876 pulls: number;
877 agent: number;
878 micros: number;
879 children: FlowNode[];
880 /** Its pull requests, for the last level: the costliest (else newest) first. */
881 list?: FlowPull[];
882};
883
884/** Nodes a column shows before the rest fold into one. */
885export const FLOW_NODES = 5;
886/** Pull requests listed for a change set. */
887export const FLOW_PULLS = 7;
888
889function fold(nodes: FlowNode[], label: string): FlowNode[] {
890 nodes.sort((a, b) => b.micros - a.micros || b.pulls - a.pulls);
891 if (nodes.length <= FLOW_NODES) return nodes;
892 const rest = nodes.slice(FLOW_NODES - 1);
893 const other: FlowNode = {
894 key: `other:${rest.map((node) => node.key).join(",")}`.slice(0, 120),
895 label,
896 to: null,
897 pulls: rest.reduce((sum, node) => sum + node.pulls, 0),
898 agent: rest.reduce((sum, node) => sum + node.agent, 0),
899 micros: rest.reduce((sum, node) => sum + node.micros, 0),
900 children: fold(
901 rest.flatMap((node) => node.children),
902 label,
903 ),
904 list: rest.flatMap((node) => node.list ?? []).slice(0, FLOW_PULLS),
905 };
906 return [...nodes.slice(0, FLOW_NODES - 1), other];
907}
908
909/**
910 * The quarter's work as a tree: milestone, work item, the work item's
911 * pull requests in each project (its change set there), and the pull
912 * requests, each node with its pull requests, how many were an agent's,
913 * and what the agents' usage on them came to. Work with no milestone or
914 * no work item has a node of its own.
915 */
916export function spendFlow(ctx: Pick<SignalContext, "slug" | "repos" | "signals" | "quarterUsage" | "quarter">): FlowNode[] {
917 const spend = quarterSpend(ctx)?.byPull ?? new Map<string, number>();
918 const items = new Map(ctx.signals.items.map((item) => [item.number, item]));
919 const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone]));
920 type Build = FlowNode & { index: Map<string, Build> };
921 const make = (key: string, label: string, to: string | null): Build => ({ key, label, to, pulls: 0, agent: 0, micros: 0, children: [], index: new Map() });
922 const roots = new Map<string, Build>();
923 const child = (parent: Map<string, Build>, key: string, label: string, to: string | null) => {
924 let node = parent.get(key);
925 if (!node) parent.set(key, (node = make(key, label, to)));
926 return node;
927 };
928 for (const pull of ctx.signals.pulls) {
929 const repo = ctx.repos[pull.repoId];
930 if (!repo) continue;
931 if (pull.createdAt.slice(0, 10) < ctx.quarter && !(pull.mergedAt && pull.mergedAt.slice(0, 10) >= ctx.quarter)) continue;
932 const micros = spend.get(pullKey(repo, pull.number)) ?? 0;
933 const milestone = pull.milestone != null ? milestones.get(pull.milestone) : undefined;
934 const item = pull.item != null ? items.get(pull.item) : undefined;
935 const path = [
936 child(roots, milestone ? `m${milestone.number}` : "m-", milestone?.title ?? "No milestone", milestone ? milestonePath(ctx.slug, milestone.number) : null),
937 ];
938 path.push(
939 child(
940 path[0]!.index,
941 `${path[0]!.key}/${item ? `i${item.number}` : "i-"}`,
942 item ? item.title : "No work item",
943 item ? itemPath(ctx.slug, item.number) : null,
944 ),
945 );
946 path.push(child(path[1]!.index, `${path[1]!.key}/${pull.repoId}`, repo.name, `/${repo.namespace}/${repo.name}`));
947 for (const node of path) {
948 node.pulls += 1;
949 node.agent += pull.agent ? 1 : 0;
950 node.micros += micros;
951 }
952 const set = path[2]!;
953 (set.list ??= []).push({
954 key: pullKey(repo, pull.number),
955 repo: repo.name,
956 number: pull.number,
957 title: pull.title,
958 agent: pull.agent,
959 micros,
960 status: pull.status,
961 to: pullPath(repo, pull.number),
962 });
963 }
964 const finish = (nodes: Map<string, Build>, label: string, depth: number): FlowNode[] =>
965 fold(
966 [...nodes.values()].map(({ index, ...node }) => ({
967 ...node,
968 children: depth < 2 ? finish(index, depth === 0 ? "Other work items" : "Other projects", depth + 1) : [],
969 list: node.list?.sort((a, b) => b.micros - a.micros || b.number - a.number).slice(0, FLOW_PULLS),
970 })),
971 label,
972 );
973 return finish(roots, "Other milestones", 0);
974}
975
976// --- The rhythm of the work -------------------------------------------------------
977
978export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number };
979
980export type RhythmRow = {
981 key: string;
982 label: string;
983 /** "3 people", or what the row is. */
984 note: string;
985 to: string;
986 days: RhythmDay[];
987 /** Everything in the fortnight, to rank and to say so. */
988 total: number;
989};
990
991export type Group = { key: string; label: string; note: string; to: string; repoIds: string[] };
992
993/** Rows of the rhythm: each group's days, quiet days too; busiest first, at most `max`. */
994export function rhythm(days: SignalDay[], list: string[], groups: Group[], max = 6): RhythmRow[] {
995 const rows = groups.map((group) => {
996 const ids = new Set(group.repoIds);
997 const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }]));
998 for (const row of days) {
999 const day = byDay.get(row.day);
1000 if (!day || !ids.has(row.repoId)) continue;
1001 day.opened += row.opened;
1002 day.merged += row.merged;
1003 day.reworked += row.reworked;
1004 day.reviews += row.reviews;
1005 day.sessions += row.sessions;
1006 }
1007 const out = list.map((day) => byDay.get(day)!);
1008 const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions, 0);
1009 return { key: group.key, label: group.label, note: group.note, to: group.to, days: out, total };
1010 });
1011 return rows
1012 .filter((row) => row.total > 0)
1013 .sort((a, b) => b.total - a.total)
1014 .slice(0, max);
1015}
1016
1017/** A row's week, added up, as its hover and its line say it. */
1018export function rhythmSum(days: RhythmDay[]) {
1019 return days.reduce(
1020 (sum, day) => ({
1021 opened: sum.opened + day.opened,
1022 merged: sum.merged + day.merged,
1023 reworked: sum.reworked + day.reworked,
1024 reviews: sum.reviews + day.reviews,
1025 sessions: sum.sessions + day.sessions,
1026 }),
1027 { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 },
1028 );
1029}
1030
1031/** How a row's this week compares with its last, in a few words. */
1032export function rhythmLine(row: RhythmRow): string {
1033 const half = Math.floor(row.days.length / 2);
1034 const last = rhythmSum(row.days.slice(0, half));
1035 const now = rhythmSum(row.days.slice(half));
1036 const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged;
1037 if (prs(now) === 0 && prs(last) > 0) return "Quiet this week after a busy last week";
1038 if (now.reviews === 0 && now.opened > 0) return `${now.opened} opened this week, no reviews yet`;
1039 if (prs(now) > 1.5 * prs(last) && prs(now) >= 4) return "Busier than last week";
1040 if (now.reworked > Math.max(2, last.reworked * 2)) return "More rework than last week";
1041 return `${now.merged} merged this week, ${last.merged} last week`;
1042}
1043
1044export type WaitingRow = WaitingPull & { repo: RepoPath; to: string };
1045
1046/** The unreviewed pull requests with their paths, for the Workspace overview's waiting list. */
1047export function waitingRows(waiting: WaitingPull[], repos: Record<string, RepoPath>, max = 8): WaitingRow[] {
1048 return waiting
1049 .filter((pull) => repos[pull.repoId])
1050 .slice(0, max)
1051 .map((pull) => ({ ...pull, repo: repos[pull.repoId]!, to: pullPath(repos[pull.repoId]!, pull.number) }));
1052}