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.

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