Signals fold in session cost, read the work every five minutes, and see practices spread
Spend per agent merge and spend threaded to the work now add each pull request's share of its harness sessions' cost (estimated at list price) to g1t's metered usage. A new finding and a Spreading list say when a harness catches on in a team. Stalled is three days with nothing happening, as the work service now counts it. Each person's reading of the work is kept for five minutes in the isolate and the data centre, so Home's refreshes don't recompute ten weeks each time. Part of #157
- G1t-Session:
- ses_01m57128wre9bvvj1y4z4b8dk0
6 files+269−330/6 viewed
| 49 | 49 | | Figure | What it counts | | |
| 50 | 50 | | --- | --- | | |
| 51 | 51 | | **Agent-authored merges** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents). | | |
| 52 | − | | **Spend per agent merge** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, divided by the agent pull requests that merged in it: an average, not what any one cost. A dash when usage can't be read or nothing an agent made merged. | | |
| 53 | − | | **Stalled changes** | Pull requests open more than seven days at the end of each week (now, for this one), neither merged nor closed. Drafts count. | | |
| 52 | + | | **Spend per agent merge** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent pull requests that merged in it: an average, not what any one cost. A dash when usage can't be read or nothing an agent made merged. | | |
| 53 | + | | **Stalled changes** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 54 | 54 | | **Agent rework rate** | Of the agent pull requests that merged in the week, the share a person asked for changes on before they merged. | | |
| 55 | 55 | ||
| 56 | 56 | ### Whose work is an agent's | |
| ⋯ | |||
| 74 | 74 | | **Work items waiting on one unreviewed change** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. | | |
| 75 | 75 | | **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. | | |
| 76 | 76 | | **A milestone shipped** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 77 | − | | **Where agent spend is going** | More than a third of the quarter's agent usage by pull request went into one milestone (else one work item, else one project). | | |
| 77 | + | | **Where agent spend is going** | More than a third of the quarter's agent spend by pull request went into one milestone (else one work item, else one project). Spend is g1t's usage on each pull request and its share of its sessions' cost. | | |
| 78 | 78 | | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. | | |
| 79 | 79 | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 80 | + | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 80 | 81 | | **Who works where** | Someone started merging to a project one person has carried (three quarters of its last 90 days' merges), or merged to a project for the first time this week after work elsewhere. | | |
| 81 | 82 | ||
| 82 | 83 | Findings are ordered by how much they hold up: blocked work first, then | |
| 83 | 84 | waits, then what shipped and where spend went. Up to five show, and the | |
| 84 | − | rest are one click away. The same reads, at more length, are on the | |
| 85 | + | rest are one click away. What the work says is read again at most every | |
| 86 | + | five minutes for each person, so a figure can be up to five minutes | |
| 87 | + | behind. The same reads, at more length, are on the | |
| 85 | 88 | [workspace overview](/guides/workspaces/#the-workspace-overview). | |
| 86 | 89 | ||
| 87 | 90 | ## Ask from Home | |
| 683 | 683 | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | 684 | each milestone, its work items, each work item's change set in each | |
| 685 | 685 | project, and that change set's pull requests. Each box is sized by | |
| 686 | − | what the agents' usage on its pull requests came to, and shaded by how | |
| 687 | − | many were an agent's (**Agent use**, less to more). Choose a box to | |
| 686 | + | what agents cost on its pull requests (g1t's metered usage, and each | |
| 687 | + | pull request's share of its sessions' cost estimated at list price), | |
| 688 | + | and shaded by how many were an agent's (**Agent use**, less to more). Choose a box to | |
| 688 | 689 | open what is under it. Work with no milestone or work item has its own | |
| 689 | 690 | box. When usage can't be read, boxes are sized by pull requests. | |
| 690 | 691 | 3. **What's moving and what's waiting**: each team's last two weeks, day | |
| ⋯ | |||
| 701 | 702 | started there this month. | |
| 702 | 703 | - **New ground**: people who merged to a project for the first time in | |
| 703 | 704 | the last two weeks, after three or more merges elsewhere. | |
| 705 | + | - **Spreading**: a harness two or more people in a team started using | |
| 706 | + | this month, at least two weeks after the first person did (across | |
| 707 | + | the workspace, when it has no teams). | |
| 704 | 708 | - **New habits**: people whose first agent-made change merged in the | |
| 705 | 709 | last two weeks, after three or more of their own. | |
| 706 | 710 | ||
| 35 | 35 | return result?.ok ? result.value : null; | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | /** How long one reading of the work is used again: Home refreshes itself often, and the weeks barely move in minutes. */ | |
| 39 | + | const KEEP_MS = 5 * 60_000; | |
| 40 | + | /** Readings kept in the isolate at once. */ | |
| 41 | + | const KEEP_MAX = 50; | |
| 42 | + | /** Where readings are kept in the data centre's cache: a name nothing outside can ask for. */ | |
| 43 | + | const KEEP_ORIGIN = "https://signals.g1t.internal/"; | |
| 44 | + | ||
| 45 | + | const kept = new Map<string, { at: number; data: SignalData }>(); | |
| 46 | + | ||
| 38 | 47 | /** | |
| 39 | − | * What the workspace's work says, for a page drawn at `now`. Null when | |
| 40 | − | * the work service answered none of the batches: the page then says so. | |
| 48 | + | * What the workspace's work says, for a page drawn at `now`, read again | |
| 49 | + | * at most every five minutes per person (what each person can read | |
| 50 | + | * differs, so a reading is never shared between people). Kept in the | |
| 51 | + | * isolate and in the data centre's cache; only a reading every batch of | |
| 52 | + | * which answered is kept. Null when the work service answered none of | |
| 53 | + | * the batches: the page then says so. | |
| 41 | 54 | */ | |
| 42 | 55 | export async function loadSignalData(viewer: User, slug: string, now: number, owned: Repo[] | null): Promise<SignalData | null> { | |
| 43 | 56 | if (!owned) return null; | |
| 57 | + | const key = `${viewer.id}/${slug}/${await digest( | |
| 58 | + | owned | |
| 59 | + | .map((repo) => repo.id) | |
| 60 | + | .sort() | |
| 61 | + | .join(","), | |
| 62 | + | )}`; | |
| 63 | + | const memory = kept.get(key); | |
| 64 | + | if (memory && now - memory.at < KEEP_MS) return memory.data; | |
| 65 | + | const shared = sharedCache(); | |
| 66 | + | const url = `${KEEP_ORIGIN}${encodeURIComponent(key)}`; | |
| 67 | + | const found = shared ? await shared.match(url).catch(() => undefined) : undefined; | |
| 68 | + | if (found) { | |
| 69 | + | const data = (await found.json().catch(() => null)) as SignalData | null; | |
| 70 | + | if (data && now - data.now < KEEP_MS) { | |
| 71 | + | remember(key, data); | |
| 72 | + | return data; | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | const data = await readSignalData(viewer, slug, now, owned); | |
| 76 | + | if (data?.complete) { | |
| 77 | + | remember(key, data); | |
| 78 | + | const response = new Response(JSON.stringify(data), { | |
| 79 | + | headers: { "content-type": "application/json", "cache-control": `private, max-age=${KEEP_MS / 1000}` }, | |
| 80 | + | }); | |
| 81 | + | // Kept for the next page; the answer never waits for it. | |
| 82 | + | void shared?.put(url, response).catch(() => undefined); | |
| 83 | + | } | |
| 84 | + | return data; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function remember(key: string, data: SignalData) { | |
| 88 | + | kept.delete(key); | |
| 89 | + | kept.set(key, { at: data.now, data }); | |
| 90 | + | while (kept.size > KEEP_MAX) kept.delete(kept.keys().next().value!); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /** The data centre's cache, or null where there is none (tests, local development). */ | |
| 94 | + | function sharedCache(): Cache | null { | |
| 95 | + | try { | |
| 96 | + | return (globalThis as { caches?: { default?: Cache } }).caches?.default ?? null; | |
| 97 | + | } catch { | |
| 98 | + | return null; | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | async function digest(text: string): Promise<string> { | |
| 103 | + | const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); | |
| 104 | + | return [...new Uint8Array(bytes).slice(0, 12)].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | async function readSignalData(viewer: User, slug: string, now: number, owned: Repo[]): Promise<SignalData | null> { | |
| 44 | 108 | const range = signalsRange(now); | |
| 45 | 109 | const starts = weekStarts(range.until, range.weeks); | |
| 46 | 110 | const ids = owned.map((repo) => repo.id); | |
| ⋯ | |||
| 62 | 126 | for (const batch of batches) if (batch.status === "rejected") console.warn("g1t: workspace signals could not be read:", batch.reason); | |
| 63 | 127 | if (ids.length > 0 && answered.length === 0) return null; | |
| 64 | 128 | const signals = mergeSignals(answered); | |
| 129 | + | const weeks = weekTotals(signals.weeks, starts); | |
| 65 | 130 | return { | |
| 66 | 131 | slug, | |
| 67 | 132 | now, | |
| ⋯ | |||
| 70 | 135 | repos, | |
| 71 | 136 | signals, | |
| 72 | 137 | complete: answered.length === batches.length, | |
| 73 | − | weeks: weekTotals(signals.weeks, starts), | |
| 74 | − | spend: weeklySpend(weekly, starts), | |
| 138 | + | weeks, | |
| 139 | + | // g1t's metered usage each week, and what the harness sessions started in it cost. | |
| 140 | + | spend: weeklySpend(weekly, starts)?.map((micros, i) => micros + Math.round((weeks[i]?.sessionCostUsd ?? 0) * 1_000_000)) ?? null, | |
| 75 | 141 | quarterUsage, | |
| 76 | 142 | }; | |
| 77 | 143 | } | |
| 14 | 14 | mergeSignals, | |
| 15 | 15 | newGround, | |
| 16 | 16 | newHabits, | |
| 17 | + | namesWords, | |
| 17 | 18 | pulse, | |
| 19 | + | quarterSpend, | |
| 18 | 20 | quarterStart, | |
| 19 | 21 | quietHeadline, | |
| 20 | 22 | reviewSignal, | |
| ⋯ | |||
| 27 | 29 | spendFlow, | |
| 28 | 30 | spendSignal, | |
| 29 | 31 | spoken, | |
| 32 | + | spreadSignal, | |
| 33 | + | spreading, | |
| 30 | 34 | stalledSignal, | |
| 31 | 35 | weekStarts, | |
| 32 | 36 | weekTotals, | |
| ⋯ | |||
| 256 | 260 | test("stalled changes and rework are findings only when they moved", () => { | |
| 257 | 261 | assert.equal( | |
| 258 | 262 | stalledSignal(ctx({ weeks: [week("a", { stalled: 16 }), week("b", { stalled: 23 })] }))!.headline, | |
| 259 | − | "23 changes have been open over a week, 7 more than last week.", | |
| 263 | + | "23 open changes have gone three days with nothing happening, 7 more than last week.", | |
| 260 | 264 | ); | |
| 261 | 265 | assert.equal(stalledSignal(ctx({ weeks: [week("a", { stalled: 16 }), week("b", { stalled: 17 })] })), null); | |
| 262 | 266 | const steady = [1, 2, 3, 4].map((i) => week(`w${i}`, { agentMerged: 10, agentReworked: 1 })); | |
| ⋯ | |||
| 380 | 384 | ], | |
| 381 | 385 | ); | |
| 382 | 386 | }); | |
| 387 | + | ||
| 388 | + | test("quarter spend adds each pull request's share of its sessions to g1t's usage", () => { | |
| 389 | + | const signals = { | |
| 390 | + | ...emptySignals(), | |
| 391 | + | pulls: [ | |
| 392 | + | pull({ number: 1, sessionCostUsd: 2.5 }), | |
| 393 | + | pull({ number: 2, sessionCostUsd: 1, createdAt: "2026-05-01T00:00:00Z", mergedAt: "2026-05-02T00:00:00Z" }), | |
| 394 | + | ], | |
| 395 | + | }; | |
| 396 | + | const quarterUsage = usage({ | |
| 397 | + | byPull: [ | |
| 398 | + | { key: "acme/web#1", micros: 1_000_000, runs: 1 }, | |
| 399 | + | { key: "acme/other#9", micros: 500_000, runs: 1 }, | |
| 400 | + | ], | |
| 401 | + | }); | |
| 402 | + | const spend = quarterSpend({ repos, signals, quarterUsage, quarter: "2026-10-01" })!; | |
| 403 | + | assert.equal(spend.byPull.get("acme/web#1"), 3_500_000); | |
| 404 | + | assert.equal(spend.byPull.get("acme/web#2"), undefined, "last quarter's sessions are left out"); | |
| 405 | + | assert.equal(spend.total, 4_000_000, "usage on pull requests not listed still counts in the total"); | |
| 406 | + | assert.equal(spend.sessions, 2_500_000); | |
| 407 | + | assert.equal(quarterSpend({ repos, signals, quarterUsage: null, quarter: "2026-10-01" }), null); | |
| 408 | + | }); | |
| 409 | + | ||
| 410 | + | test("a harness catching on: two or more people started this month, two weeks or more after the first", () => { | |
| 411 | + | const at = (days: number) => new Date(NOW - days * DAY).toISOString(); | |
| 412 | + | const harnesses = [ | |
| 413 | + | { repoId: "r1", ownerId: "u1", ownerName: "ana", harness: "codex", firstAt: at(60), sessions: 30 }, | |
| 414 | + | { repoId: "r2", ownerId: "u1", ownerName: "ana", harness: "codex", firstAt: at(20), sessions: 3 }, | |
| 415 | + | { repoId: "r1", ownerId: "u2", ownerName: "bo", harness: "codex", firstAt: at(10), sessions: 4 }, | |
| 416 | + | { repoId: "r2", ownerId: "u3", ownerName: "cy", harness: "codex", firstAt: at(3), sessions: 2 }, | |
| 417 | + | { repoId: "r1", ownerId: "u2", ownerName: "bo", harness: "cursor", firstAt: at(5), sessions: 1 }, | |
| 418 | + | ]; | |
| 419 | + | const [spread] = spreading(harnesses, NOW); | |
| 420 | + | assert.equal(spread!.harness, "codex"); | |
| 421 | + | assert.equal(spread!.first.name, "ana", "a person's first session anywhere counts"); | |
| 422 | + | assert.deepEqual( | |
| 423 | + | spread!.recent.map((p) => p.name), | |
| 424 | + | ["bo", "cy"], | |
| 425 | + | ); | |
| 426 | + | assert.equal(spreading(harnesses, NOW, { only: new Set(["r1"]) }).length, 0, "within one project only bo is new"); | |
| 427 | + | assert.equal( | |
| 428 | + | spreadSignal(ctx({ signals: { ...emptySignals(), harnesses } }))!.headline, | |
| 429 | + | "Codex is catching on: bo and cy started using it this month, after ana.", | |
| 430 | + | ); | |
| 431 | + | assert.equal(namesWords(["a", "b", "c", "d"]), "a, b and 2 others"); | |
| 432 | + | }); | |
| 15 | 15 | RepoPath, | |
| 16 | 16 | SignalAuthor, | |
| 17 | 17 | SignalDay, | |
| 18 | + | SignalHarness, | |
| 18 | 19 | SignalItem, | |
| 19 | 20 | SignalMilestone, | |
| 20 | 21 | SignalPull, | |
| ⋯ | |||
| 310 | 311 | delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false), | |
| 311 | 312 | series: perMerge, | |
| 312 | 313 | 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 | + | hint: "g1t's agent usage at price and harness sessions' cost at list price, over the agent merges", | |
| 314 | 315 | to: `/${slug}/-/usage`, | |
| 315 | 316 | }, | |
| 316 | 317 | { | |
| ⋯ | |||
| 319 | 320 | value: String(now.stalled), | |
| 320 | 321 | delta: deltaOf(now.stalled, before?.stalled ?? null, count, false), | |
| 321 | 322 | 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", | |
| 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", | |
| 324 | 325 | to: `/${slug}/-/overview?tab=needs#work`, | |
| 325 | 326 | }, | |
| 326 | 327 | { | |
| ⋯ | |||
| 452 | 453 | return null; | |
| 453 | 454 | } | |
| 454 | 455 | ||
| 455 | − | /** What each of the quarter's pull requests cost, by `namespace/name#number`. */ | |
| 456 | − | function 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; | |
| 456 | + | const MICROS = 1_000_000; | |
| 457 | + | ||
| 458 | + | /** Whether a pull request's work falls in the quarter: opened in it, or merged in it. */ | |
| 459 | + | function inQuarter(pull: SignalPull, quarter: string): boolean { | |
| 460 | + | return pull.createdAt.slice(0, 10) >= quarter || (pull.mergedAt != null && pull.mergedAt.slice(0, 10) >= quarter); | |
| 460 | 461 | } | |
| 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 | + | */ | |
| 470 | + | export 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 | + | ||
| 462 | 494 | function pullKey(repo: RepoPath, number: number): string { | |
| 463 | 495 | return `${repo.namespace}/${repo.name}#${number}`.toLowerCase(); | |
| 464 | 496 | } | |
| 465 | 497 | ||
| 466 | 498 | /** Where the quarter's agent usage went, by milestone (else work item, else project): the biggest share. */ | |
| 467 | 499 | export 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; | |
| 500 | + | const quarter = quarterSpend(ctx); | |
| 501 | + | if (!quarter || quarter.total <= 0) return null; | |
| 502 | + | const { byPull: spend, total } = quarter; | |
| 473 | 503 | const items = new Map(ctx.signals.items.map((item) => [item.number, item])); | |
| 474 | 504 | const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone])); | |
| 475 | 505 | const groups = new Map<string, { label: string; to: string; micros: number }>(); | |
| ⋯ | |||
| 552 | 582 | if (more < 3 || now.stalled < 1.25 * before.stalled) return null; | |
| 553 | 583 | return { | |
| 554 | 584 | key: "stalled", | |
| 555 | − | headline: `${countWord(now.stalled)} changes have been open over a week, ${more} more than last week.`, | |
| 585 | + | headline: `${countWord(now.stalled)} open changes have gone three days with nothing happening, ${more} more than last week.`, | |
| 556 | 586 | facts: [`${before.stalled} last week`, `${now.stalled} now`], | |
| 557 | 587 | link: { label: "See what's stalled", to: `/${ctx.slug}/-/overview?tab=needs#work` }, | |
| 558 | 588 | trend: { | |
| ⋯ | |||
| 662 | 692 | return found.sort((a, b) => b.firstAgentAt.localeCompare(a.firstAgentAt)); | |
| 663 | 693 | } | |
| 664 | 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 | + | */ | |
| 701 | + | export 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 | + | ||
| 708 | + | export 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 | + | ||
| 728 | + | const 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. */ | |
| 737 | + | export 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". */ | |
| 742 | + | export 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. */ | |
| 749 | + | export 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 | + | ||
| 665 | 766 | /** The quiet changes as one finding: someone moving into a project one person has carried, else anyone new to one. */ | |
| 666 | 767 | export function groundSignal(ctx: SignalContext): Signal | null { | |
| 667 | 768 | const spreading = silos(ctx.signals.authors, ctx.now).find((silo) => silo.joining.length > 0 && ctx.repos[silo.repoId]); | |
| ⋯ | |||
| 695 | 796 | }; | |
| 696 | 797 | } | |
| 697 | 798 | ||
| 698 | − | const FINDINGS = [blockedSignal, reviewSignal, shippedSignal, spendSignal, stalledSignal, reworkSignal, groundSignal]; | |
| 799 | + | const FINDINGS = [blockedSignal, reviewSignal, shippedSignal, spendSignal, stalledSignal, reworkSignal, groundSignal, spreadSignal]; | |
| 699 | 800 | ||
| 700 | 801 | /** Every finding worth a look today, the one that matters most first. A finding that cannot be worked out is left out. */ | |
| 701 | 802 | export function findings(ctx: SignalContext): Signal[] { | |
| ⋯ | |||
| 813 | 914 | * no work item has a node of its own. | |
| 814 | 915 | */ | |
| 815 | 916 | export function spendFlow(ctx: Pick<SignalContext, "slug" | "repos" | "signals" | "quarterUsage" | "quarter">): FlowNode[] { | |
| 816 | − | const spend = pullSpend(ctx.quarterUsage); | |
| 917 | + | const spend = quarterSpend(ctx)?.byPull ?? new Map<string, number>(); | |
| 817 | 918 | const items = new Map(ctx.signals.items.map((item) => [item.number, item])); | |
| 818 | 919 | const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone])); | |
| 819 | 920 | type Build = FlowNode & { index: Map<string, Build> }; | |
| 24 | 24 | import { SpendFlow } from "../../components/spend-flow"; | |
| 25 | 25 | import { isFailed, part } from "../../lib/partial"; | |
| 26 | 26 | import { loadRepos } from "../../lib/home.server"; | |
| 27 | − | import { dailySignal, dayList, dollars, newGround, newHabits, rhythm, silos, spendFlow, spoken, waitingRows } from "../../lib/signals"; | |
| 27 | + | import { dailySignal, dayList, dollars, harnessWords, namesWords, newGround, newHabits, quarterSpend, rhythm, silos, spendFlow, spoken, spreading, waitingRows } from "../../lib/signals"; | |
| 28 | 28 | import { type SignalData, loadGroups, loadSignalData } from "../../lib/signals.server"; | |
| 29 | 29 | ||
| 30 | 30 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 83 | 83 | function shapeGround(found: SignalData, groups: Awaited<ReturnType<typeof loadGroups>>, now: number) { | |
| 84 | 84 | const name = (id: string) => found.repos[id]?.name ?? "a project"; | |
| 85 | 85 | const path = (id: string) => (found.repos[id] ? `/${found.repos[id]!.namespace}/${found.repos[id]!.name}` : null); | |
| 86 | − | const quarterSpend = found.quarterUsage ? found.quarterUsage.byPull.reduce((sum, slice) => sum + slice.micros, 0) : null; | |
| 86 | + | const quarter = quarterSpend({ repos: found.repos, signals: found.signals, quarterUsage: found.quarterUsage, quarter: found.range.quarter }); | |
| 87 | + | // Practices catching on in each team (else across the workspace), the newest first. | |
| 88 | + | const scopes = groups?.by === "team" ? groups.groups.map((group) => ({ key: group.key, label: group.label, only: new Set(group.repoIds) as ReadonlySet<string> | undefined })) : [{ key: "all", label: "the workspace", only: undefined }]; | |
| 89 | + | const spreads = scopes.flatMap((scope) => spreading(found.signals.harnesses, now, { only: scope.only }).map((spread) => ({ scope, spread }))); | |
| 87 | 90 | return { | |
| 88 | 91 | tiles: dailySignal(found).tiles, | |
| 89 | 92 | complete: found.complete, | |
| 90 | 93 | capped: found.signals.pullsCapped, | |
| 91 | 94 | flow: spendFlow({ slug: found.slug, repos: found.repos, signals: found.signals, quarterUsage: found.quarterUsage, quarter: found.range.quarter }), | |
| 92 | − | sized: (quarterSpend ?? 0) > 0 ? ("spend" as const) : ("pulls" as const), | |
| 93 | − | quarterSpend, | |
| 95 | + | sized: (quarter?.total ?? 0) > 0 ? ("spend" as const) : ("pulls" as const), | |
| 96 | + | quarterSpend: quarter?.total ?? null, | |
| 97 | + | sessionSpend: quarter?.sessions ?? 0, | |
| 94 | 98 | rhythm: groups ? { by: groups.by, rows: rhythm(found.signals.days, dayList(found.range.until, found.range.days), groups.groups) } : null, | |
| 95 | 99 | waiting: waitingRows(found.signals.waiting, found.repos).map((pull) => ({ ...pull, age: spoken(Math.max(0, (now - Date.parse(pull.createdAt)) / 1000)) })), | |
| 96 | 100 | silos: silos(found.signals.authors, now) | |
| ⋯ | |||
| 105 | 109 | newGround: newGround(found.signals.authors, now) | |
| 106 | 110 | .slice(0, QUIET_ROWS) | |
| 107 | 111 | .map((row) => ({ key: `${row.ownerId}:${row.repoId}`, to: path(row.repoId), where: name(row.repoId), line: `${row.name} merged a first change here`, more: `${row.elsewhere} merges elsewhere first` })), | |
| 112 | + | spreading: spreads.slice(0, QUIET_ROWS).map(({ scope, spread }) => ({ | |
| 113 | + | key: `${scope.key}:${spread.harness}`, | |
| 114 | + | to: null, | |
| 115 | + | where: `${harnessWords(spread.harness)} in ${scope.label}`, | |
| 116 | + | line: `${namesWords(spread.recent.map((person) => person.name))} started using it this month, after ${spread.first.name}`, | |
| 117 | + | more: `${spread.people} people use it now`, | |
| 118 | + | })), | |
| 108 | 119 | habits: newHabits(found.signals.pulls, now) | |
| 109 | 120 | .slice(0, QUIET_ROWS) | |
| 110 | 121 | .map((row) => ({ key: row.ownerId, to: path(row.repoId), where: name(row.repoId), line: `${row.name} merged a first agent-made change`, more: `after ${row.byHand} by hand` })), | |
| ⋯ | |||
| 287 | 298 | title={ground.quarterSpend ? `${dollars(ground.quarterSpend)} of agent spend this quarter, and what it went into` : "This quarter's work, and how much of it agents did"} | |
| 288 | 299 | hint={ | |
| 289 | 300 | ground.sized === "spend" | |
| 290 | − | ? "Each milestone, its work items, their change set in each project and its pull requests, sized by what the agents' usage on them came to and shaded by how much was an agent's. Choose a box to open what is under it." | |
| 301 | + | ? `Each milestone, its work items, their change set in each project and its pull requests, sized by what agents cost on them${ground.sessionSpend > 0 ? ` (g1t's metered usage, and ${dollars(ground.sessionSpend)} of harness sessions estimated at list price)` : ""} and shaded by how much was an agent's. Choose a box to open what is under it.` | |
| 291 | 302 | : "Usage isn't known for this quarter, so each box is sized by its pull requests and shaded by how many were an agent's. Choose a box to open what is under it." | |
| 292 | 303 | } | |
| 293 | 304 | > | |
| ⋯ | |||
| 340 | 351 | title="Who works where, and what is spreading" | |
| 341 | 352 | hint="What no roll-up shows: knowledge resting with one person, people moving into new ground, and new ways of working taking hold. Each is a place to look, not a verdict." | |
| 342 | 353 | > | |
| 343 | − | <div className="grid gap-8 md:grid-cols-3"> | |
| 354 | + | <div className="grid gap-8 md:grid-cols-2 xl:grid-cols-4"> | |
| 344 | 355 | <QuietList title="Knowledge silos" hint="Projects where one person has done most of the last 90 days' work" rows={ground.silos} empty="No project rests on one person right now." /> | |
| 345 | 356 | <QuietList title="New ground" hint="People who merged to a project for the first time in the last two weeks" rows={ground.newGround} empty="No one has moved into a new project in the last two weeks." /> | |
| 357 | + | <QuietList title="Spreading" hint="A harness more people took up this month, after someone started with it" rows={ground.spreading} empty="No harness has caught on in a team this month." /> | |
| 346 | 358 | <QuietList title="New habits" hint="People whose first agent-made change merged in the last two weeks" rows={ground.habits} empty="No one new started merging agents' work in the last two weeks." /> | |
| 347 | 359 | </div> | |
| 348 | 360 | </Section> | |
Comments
Sign in to comment.