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.

Commit

Merge pull request #163 from flagon-io/signals-tuning

Home's daily signal reads again, and counts agents' direct pushes

syntaqxcommitted Parents7ecaac1159f042Browse files
12 files+198−750/12 viewed
+4−4
4848
4949 | Figure | What it counts |
5050 | --- | --- |
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, 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. |
51+| **Agent-authored changes** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. |
52+| **Spend per agent change** | 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 changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. |
5353 | **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−| **Agent rework rate** | Of the agent pull requests that merged in the week, the share a person asked for changes on before they merged. |
54+| **Agent rework rate** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. |
5555
5656 ### Whose work is an agent's
5757
7878 | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. |
7979 | **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). |
8080 | **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. |
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+| **Who works where** | In a workspace where three people or more merged lately, 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. |
8282
8383 Findings are ordered by how much they hold up: blocked work first, then
8484 waits, then what shipped and where spend went. Up to five show, and the
+7−4
682682 figures as Home.
683683 2. **Spend, threaded to the work**: the quarter's work in four columns:
684684 each milestone, its work items, each work item's change set in each
685− project, and that change set's pull requests. Each box is sized by
685+ project, and that change set's pull requests (with no milestones in
686+ the workspace, it starts at the work items). Each box is sized by
686687 what agents cost on its pull requests (g1t's metered usage, and each
687688 pull request's share of its sessions' cost estimated at list price),
688689 and shaded by how many were an agent's (**Agent use**, less to more). Choose a box to
694695 organization ran, so it is never added to g1t's.
695696 3. **What's moving and what's waiting**: each team's last two weeks, day
696697 by day, by the projects the team works on (each project, when the
697− workspace has no teams). Above the line are pull requests opened and
698− merged, and agent sessions started; below it, reviews and comments and
698+ workspace has no teams). Above the line are changes (pull requests
699+ opened and merged, and commits agents pushed outside one) and agent
700+ sessions started; below it, reviews and comments and
699701 requests for changes. Turn each layer on or off above the rows, point
700702 at a day for its numbers, or open **The numbers, as a table**. Each
701703 row is drawn to its own scale. Beside it, **Waiting on a first
703705 4. **Quiet changes**: places to look, not verdicts.
704706 - **Knowledge silos**: projects where one person merged three quarters
705707 or more of the last 90 days' changes (six or more), and whoever has
706− started there this month.
708+ started there this month. Only in a workspace where three people or
709+ more merged lately: with fewer, every project rests on someone.
707710 - **New ground**: people who merged to a project for the first time in
708711 the last two weeks, after three or more merges elsewhere.
709712 - **Spreading**: a harness two or more people in a team started using
+10−7
11 /**
22 * What is moving and what is waiting (lib/signals.ts rhythm): each team's
33 * last two weeks, day by day. Above the line, pull requests opened and
4− * merged (bars) and agent sessions started (a wash); below it, reviews and
4+ * merged and commits agents pushed outside one (bars), and agent sessions
5+ * started (a wash); below it, reviews and
56 * comments (a wash) and requests for changes (bars). Each row is drawn to
67 * its own scale, so a small team's week is as legible as a big one's; the
78 * numbers are in the readout and the table. Layers turn on and off above
1617 type Layer = "prs" | "rework" | "agent" | "reviews";
1718
1819 const LAYERS: { key: Layer; label: string; swatch: string }[] = [
19− { key: "prs", label: "PR activity", swatch: "bg-fg-soft" },
20+ { key: "prs", label: "Changes", swatch: "bg-fg-soft" },
2021 { key: "rework", label: "Rework", swatch: "bg-faint" },
2122 { key: "agent", label: "Agent activity", swatch: "bg-series-1" },
2223 { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" },
5455 <ul className="mt-2 space-y-1 text-fg">
5556 <li className="flex items-center gap-2">
5657 <span className="size-2 rounded-full bg-fg-soft" />
57− {sum.opened} opened · {sum.merged} merged
58+ {sum.opened} opened · {sum.merged} merged{sum.commits > 0 && ` · ${sum.commits} pushed by agents`}
5859 </li>
5960 <li className="flex items-center gap-2">
6061 <span className="size-2 rounded-full bg-faint" />
8990 const n = row.days.length;
9091 const slot = WIDTH / n;
9192 const x = (i: number) => slot * i + slot / 2;
92− const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged : 0, on.has("agent") ? day.sessions : 0)));
93+ const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged + day.commits : 0, on.has("agent") ? day.sessions : 0)));
9394 const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0)));
9495 const above = (value: number) => MID - (value / up) * (MID - 6);
9596 const below = (value: number) => MID + (value / down) * (MID - 6);
133134 <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" />
134135 {on.has("prs") &&
135136 row.days.map((d, i) =>
136− d.opened + d.merged > 0 ? (
137+ d.opened + d.merged + d.commits > 0 ? (
137138 <rect
138139 key={`p${i}`}
139140 x={x(i) - BAR / 2}
140− y={above(d.opened + d.merged)}
141+ y={above(d.opened + d.merged + d.commits)}
141142 width={BAR}
142− height={MID - above(d.opened + d.merged)}
143+ height={MID - above(d.opened + d.merged + d.commits)}
143144 rx={1.5}
144145 className="fill-fg-soft"
145146 />
224225 <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th>
225226 <th className="py-1 font-normal">Opened</th>
226227 <th className="py-1 font-normal">Merged</th>
228+ <th className="py-1 font-normal">Pushed by agents</th>
227229 <th className="py-1 font-normal">Rework</th>
228230 <th className="py-1 font-normal">Reviews &amp; comments</th>
229231 <th className="py-1 font-normal">Agent sessions</th>
240242 <td className="py-1.5 text-fg">{row.label}</td>
241243 <td>{cell(last.opened, now.opened)}</td>
242244 <td>{cell(last.merged, now.merged)}</td>
245+ <td>{cell(last.commits, now.commits)}</td>
243246 <td>{cell(last.reworked, now.reworked)}</td>
244247 <td>{cell(last.reviews, now.reviews)}</td>
245248 <td>{cell(last.sessions, now.sessions)}</td>
+14−10
141141 export function SpendFlow({ flow, sized }: { flow: FlowNode[]; sized: "spend" | "pulls" }) {
142142 const [path, setPath] = useState<string[]>([]);
143143 const weight = weightOf(sized);
144+ // A workspace with no milestones starts at its work items: one "No milestone" box tells nothing.
145+ const skip = flow.length === 1 && flow[0]!.key === "m-";
146+ const depth = skip ? 2 : 3;
147+ const heads = HEADS.slice(3 - depth);
144148 // The chosen node at each level: the one asked for, else the biggest.
145− const columns: FlowNode[][] = [flow];
149+ const columns: FlowNode[][] = [skip ? flow[0]!.children : flow];
146150 const chosen: FlowNode[] = [];
147− for (let level = 0; level < 3; level++) {
151+ for (let level = 0; level < depth; level++) {
148152 const nodes = columns[level] ?? [];
149153 const pick = nodes.find((node) => node.key === path[level]) ?? nodes[0];
150154 if (!pick) break;
151155 chosen.push(pick);
152− if (level < 2) columns.push(pick.children);
156+ if (level < depth - 1) columns.push(pick.children);
153157 }
154− const pulls = chosen[2]?.list ?? [];
158+ const pulls = chosen[depth - 1]?.list ?? [];
155159 const choose = (level: number, key: string) => setPath([...chosen.slice(0, level).map((node) => node.key), key]);
156160 const boxes = columns.map((nodes) => stack(nodes.map(weight)));
157161 const rowHeight = pulls.length > 0 ? HEIGHT / Math.max(pulls.length, 5) : 0;
162166 return (
163167 <div className="space-y-4">
164168 <Legend />
165− {/* Wide: the four columns, joined. */}
169+ {/* Wide: the columns, joined. */}
166170 <div className="hidden md:block">
167171 <div className="relative h-7">
168− {HEADS.map((head, column) => (
172+ {heads.map((head, column) => (
169173 <p key={head} className="absolute top-0 font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase" style={{ left: `${left(column)}%` }}>
170174 {head}
171175 </p>
176180 {chosen.map((node, level) => {
177181 const index = columns[level]!.indexOf(node);
178182 const from = boxes[level]![index]!;
179− if (level < 2) {
183+ if (level < depth - 1) {
180184 const children = columns[level + 1]!;
181185 return slices(from, children.map(weight)).map((slice, i) => (
182186 <path key={`${level}-${i}`} d={band(level, slice, boxes[level + 1]![i]!)} className="fill-line-strong" opacity={0.45} />
209213 )}
210214 <div
211215 className="absolute rounded-xl border border-line bg-surface"
212− style={{ left: `${left(3)}%`, width: `${100 - left(3)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }}
216+ style={{ left: `${left(depth)}%`, width: `${100 - left(depth)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }}
213217 >
214218 {pulls.length === 0 ? (
215219 <p className="px-3 py-4 text-sm text-muted">No pull requests here.</p>
223227 <div className="space-y-5 md:hidden">
224228 {columns.map((nodes, level) => (
225229 <div key={level} className="space-y-2">
226− <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[level]}</p>
230+ <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{heads[level]}</p>
227231 <div className="grid gap-2">
228232 {nodes.map((node) => (
229233 <NodeBox key={node.key} node={node} sized={sized} chosen={chosen[level]?.key === node.key} onChoose={() => choose(level, node.key)} />
232236 </div>
233237 ))}
234238 <div className="space-y-2">
235− <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[3]}</p>
239+ <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{heads[depth]}</p>
236240 <div className="rounded-xl border border-line bg-surface">
237241 {pulls.map((pull) => (
238242 <PullRow key={pull.key} pull={pull} style={{ height: 48 }} />
+19−2
277277 author({ ownerId: "u1", ownerName: "ana", repoId: "r1", recent: 9, merged: 40 }),
278278 author({ ownerId: "u2", ownerName: "bo", repoId: "r1", firstMergedAt: recent, recent: 1, merged: 1 }),
279279 author({ ownerId: "u2", ownerName: "bo", repoId: "r2", firstMergedAt: "2026-02-01T00:00:00Z", merged: 12, recent: 2 }),
280+ author({ ownerId: "u3", ownerName: "cy", repoId: "r2", firstMergedAt: "2026-03-01T00:00:00Z", merged: 5, recent: 1 }),
280281 ];
281282 assert.deepEqual(
282283 newGround(authors, NOW).map((row) => [row.name, row.repoId, row.elsewhere]),
283284 [["bo", "r1", 12]],
284285 );
286+ assert.deepEqual(silos(authors.slice(0, 3), NOW), [], "two people: every project rests on one of them, which says nothing");
285287 const [silo] = silos(authors, NOW);
286288 assert.equal(silo!.name, "ana");
287289 assert.equal(silo!.share, 0.9);
306308 found.map((s) => s.key),
307309 ["blocked", "stalled"],
308310 );
309− assert.equal(quietHeadline(week("x")), "A quiet week: nothing has merged yet, and nothing is stuck.");
311+ assert.equal(quietHeadline(week("x")), "A quiet week: nothing has landed yet, and nothing is stuck.");
310312 assert.equal(
311313 quietHeadline(week("x", { merged: 4, agentMerged: 2 })),
312− "Four changes merged this week, 50% of them an agent's, and nothing needs a closer look.",
314+ "Four changes landed this week, 50% of them an agent's, and nothing needs a closer look.",
315+ );
316+ // A workspace that pushes straight to main: agents' direct commits are what landed.
317+ assert.equal(
318+ quietHeadline(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })),
319+ "Ten changes landed this week, 90% of them an agent's, and nothing needs a closer look.",
313320 );
314321 });
315322
430437 );
431438 assert.equal(namesWords(["a", "b", "c", "d"]), "a, b and 2 others");
432439 });
440+
441+test("agents' direct commits count as what they landed, and as what spend is spread over", () => {
442+ const weeks = [week("2026-10-05", { merged: 2, agentMerged: 1, agentCommits: 3 }), week("2026-10-12", { merged: 1, agentMerged: 1, agentCommits: 9 })];
443+ const [changes, spend] = pulse({ slug: "acme", weeks, spend: [4_000_000, 20_000_000] });
444+ assert.equal(changes!.label, "Agent-authored changes");
445+ assert.equal(changes!.value, "10");
446+ assert.deepEqual(changes!.delta, { text: "+6", tone: "good" });
447+ assert.equal(changes!.points[1], "Week of Oct 12: 1 merged, 9 pushed directly, of 10 changes");
448+ assert.equal(spend!.value, "$2.00", "$20 over ten agent changes");
449+});
+50−25
114114 reviewSeconds: 0,
115115 stalled: 0,
116116 sessionCostUsd: 0,
117+ agentCommits: 0,
117118 });
118119
119120 /** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */
131132 week.reviewSeconds += row.reviewSeconds;
132133 week.stalled += row.stalled;
133134 week.sessionCostUsd += row.sessionCostUsd ?? 0;
135+ week.agentCommits += row.agentCommits ?? 0;
134136 }
135137 return starts.map((start) => byStart.get(start)!);
136138 }
172174 return week.agentMerged > 0 ? week.agentReworked / week.agentMerged : null;
173175 }
174176
175−/** The share of merges that were an agent's, 0 to 1. */
177+/**
178+ * What agents landed in a week: their merged pull requests, and the
179+ * commits their sessions pushed outside any pull request (to the default
180+ * branch, most of all). A workspace that pushes straight to main lands
181+ * most of its work the second way.
182+ */
183+export function agentChanges(week: WeekTotals): number {
184+ return week.agentMerged + (week.agentCommits ?? 0);
185+}
186+
187+/** Everything that landed in a week: merged pull requests, and agents' direct commits. */
188+export function allChanges(week: WeekTotals): number {
189+ return week.merged + (week.agentCommits ?? 0);
190+}
191+
192+/** The share of what landed that was an agent's, 0 to 1. */
176193 export function agentShare(week: WeekTotals): number | null {
177− return week.merged > 0 ? week.agentMerged / week.merged : null;
194+ return allChanges(week) > 0 ? agentChanges(week) / allChanges(week) : null;
178195 }
179196
180−/** Usage per agent merge, in micros. */
197+/** Usage per agent change (merge or direct commit), in micros. */
181198 export function spendPerMerge(micros: number | null, week: WeekTotals): number | null {
182− return micros != null && week.agentMerged > 0 ? micros / week.agentMerged : null;
199+ return micros != null && agentChanges(week) > 0 ? micros / agentChanges(week) : null;
183200 }
184201
185202 // --- Words ------------------------------------------------------------------------
282299
283300 /**
284301 * The past week in four figures, each against the week before and drawn
285− * over the last ten: agent merges, usage per agent merge, stalled changes,
286− * and how often people asked for changes on an agent's work.
302+ * over the last ten: what agents landed (merges and direct commits), usage
303+ * per agent change, stalled changes, and how often people asked for
304+ * changes on an agent's merged work.
287305 */
288306 export function pulse({ slug, weeks, spend }: PulseInput): PulseTile[] {
289307 const at = weeks.length - 1;
297315 return [
298316 {
299317 key: "merges",
300− label: "Agent-authored merges",
301− value: String(now.agentMerged),
302− delta: deltaOf(now.agentMerged, before?.agentMerged ?? null, count, true),
303− series: weeks.map((week) => week.agentMerged),
304− points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} of ${week.merged} merges`),
305− hint: "Merged pull requests an agent made: g1t's agent, or a harness session tied to it",
318+ label: "Agent-authored changes",
319+ value: String(agentChanges(now)),
320+ delta: deltaOf(agentChanges(now), before ? agentChanges(before) : null, count, true),
321+ series: weeks.map(agentChanges),
322+ points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} merged, ${week.agentCommits ?? 0} pushed directly, of ${allChanges(week)} changes`),
323+ hint: "Agents' merged pull requests, and the commits their sessions pushed outside one",
306324 to: `/${slug}/-/overview?tab=landed`,
307325 },
308326 {
309327 key: "spend",
310− label: "Spend per agent merge",
328+ label: "Spend per agent change",
311329 value: dollars(perMerge[at] ?? null),
312330 delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false),
313331 series: perMerge,
314− points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent merges" : dollars(micros)}`),
315− hint: "g1t's agent usage at price and harness sessions' cost at list price, over the agent merges",
332+ points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent changes" : dollars(micros)}`),
333+ hint: "g1t's agent usage at price and harness sessions' cost at list price, over what agents landed",
316334 to: `/${slug}/-/usage`,
317335 },
318336 {
646664 /**
647665 * A project where one person has done most of the recent work: their
648666 * share of its merges in the last 90 days, and whoever else has started
649− * merging there in the last 30 (knowledge spreading, or not yet).
667+ * merging there in the last 30 (knowledge spreading, or not yet). Only in
668+ * a workspace where three people or more merged lately.
650669 */
651670 export type Silo = {
652671 repoId: string;
658677 joining: { ownerId: string; name: string }[];
659678 };
660679
661−export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75 } = {}): Silo[] {
680+export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75, minPeople = 3 } = {}): Silo[] {
681+ // Fewer than `minPeople` people merging anywhere lately: every project rests on someone, and saying so tells nothing.
682+ const people = new Set(authors.filter((row) => row.recent > 0).map((row) => row.ownerId));
683+ if (people.size < minPeople) return [];
662684 const byRepo = new Map<string, SignalAuthor[]>();
663685 for (const row of authors) byRepo.set(row.repoId, [...(byRepo.get(row.repoId) ?? []), row]);
664686 const found: Silo[] = [];
815837
816838 /** The headline when nothing stands out: still a sentence about the week. */
817839 export function quietHeadline(week: WeekTotals | undefined): string {
818− if (!week || week.merged === 0) return "A quiet week: nothing has merged yet, and nothing is stuck.";
840+ const landed = week ? allChanges(week) : 0;
841+ if (!week || landed === 0) return "A quiet week: nothing has landed yet, and nothing is stuck.";
819842 const share = agentShare(week);
820− 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.`;
843+ return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`;
821844 }
822845
823846 /** What Home shows: the pulse, and the findings with the one that leads. */
976999
9771000 // --- The rhythm of the work -------------------------------------------------------
9781001
979−export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number };
1002+export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number; commits: number };
9801003
9811004 export type RhythmRow = {
9821005 key: string;
9951018 export function rhythm(days: SignalDay[], list: string[], groups: Group[], max = 6): RhythmRow[] {
9961019 const rows = groups.map((group) => {
9971020 const ids = new Set(group.repoIds);
998− const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }]));
1021+ const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 }]));
9991022 for (const row of days) {
10001023 const day = byDay.get(row.day);
10011024 if (!day || !ids.has(row.repoId)) continue;
10041027 day.reworked += row.reworked;
10051028 day.reviews += row.reviews;
10061029 day.sessions += row.sessions;
1030+ day.commits += row.agentCommits ?? 0;
10071031 }
10081032 const out = list.map((day) => byDay.get(day)!);
1009− const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions, 0);
1033+ const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions + day.commits, 0);
10101034 return { key: group.key, label: group.label, note: group.note, to: group.to, days: out, total };
10111035 });
10121036 return rows
10241048 reworked: sum.reworked + day.reworked,
10251049 reviews: sum.reviews + day.reviews,
10261050 sessions: sum.sessions + day.sessions,
1051+ commits: sum.commits + day.commits,
10271052 }),
1028− { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 },
1053+ { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 },
10291054 );
10301055 }
10311056
10341059 const half = Math.floor(row.days.length / 2);
10351060 const last = rhythmSum(row.days.slice(0, half));
10361061 const now = rhythmSum(row.days.slice(half));
1037− const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged;
1062+ const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged + sum.commits;
10381063 if (prs(now) === 0 && prs(last) > 0) return "Quiet this week after a busy last week";
10391064 if (now.reviews === 0 && now.opened > 0) return `${now.opened} opened this week, no reviews yet`;
10401065 if (prs(now) > 1.5 * prs(last) && prs(now) >= 4) return "Busier than last week";
10411066 if (now.reworked > Math.max(2, last.reworked * 2)) return "More rework than last week";
1042− return `${now.merged} merged this week, ${last.merged} last week`;
1067+ return `${now.merged + now.commits} landed this week, ${last.merged + last.commits} last week`;
10431068 }
10441069
10451070 export type WaitingRow = WaitingPull & { repo: RepoPath; to: string };
+5−0
5858 /// What the harness sessions started in the week cost, as they
5959 /// reported it (estimated at list price from their token usage).
6060 pub session_cost_usd: f64,
61+ /// Commits agent sessions pushed outside any pull request in the week
62+ /// (to the default branch, most of all), each once.
63+ pub agent_commits: u32,
6164 }
6265
6366 /// One repository's UTC day. Days with nothing in them are left out.
7679 pub reviews: u32,
7780 /// Harness sessions started.
7881 pub sessions: u32,
82+ /// Commits agent sessions pushed outside any pull request.
83+ pub agent_commits: u32,
7984 }
8085
8186 /// An open pull request no one has reviewed yet, oldest first.
+3−1
19081908 reviewSeconds: number;
19091909 stalled: number;
19101910 sessionCostUsd: number;
1911+ /** Commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. */
1912+ agentCommits: number;
19111913 };
1912−export type SignalDay = { repoId: string; day: string; opened: number; merged: number; agentMerged: number; reworked: number; reviews: number; sessions: number };
1914+export type SignalDay = { repoId: string; day: string; opened: number; merged: number; agentMerged: number; reworked: number; reviews: number; sessions: number; agentCommits: number };
19131915 /** An open pull request no one has reviewed, oldest first; `blocks`: the open work items waiting on its work item. */
19141916 export type WaitingPull = { repoId: string; number: number; title: string; createdAt: string; agent: boolean; draftSeconds: number | null; item: number | null; blocks: number[] };
19151917 export type SignalPull = {
+2−1
2323 sha2 = "0.10"
2424
2525 [dev-dependencies]
26−rusqlite = { version = "0.32", features = ["bundled"] }
26+# limits: the tests' databases refuse what D1 refuses (signals/tests.rs).
27+rusqlite = { version = "0.32", features = ["bundled", "limits"] }
2728
2829 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
2930 # time.
+13−7
22 -- `YYYY-MM-DDT00:00:00`. One row per repository and UTC day that had
33 -- anything in it. Reviews are comments of any kind on a pull request by a
44 -- person other than its owner (not g1t's agent, a workspace agent or g1t
5−-- itself); reworked, the ones of them that ask for changes.
5+-- itself); reworked, the ones of them that ask for changes. Agent commits
6+-- are those agent sessions pushed outside any pull request (weeks.sql).
67 ,
7−ev(repo_id, day, opened, merged, agent_merged, reworked, reviews, sessions) AS (
8− SELECT repo_id, substr(created_at, 1, 10), 1, 0, 0, 0, 0, 0 FROM pr
8+ev(repo_id, day, opened, merged, agent_merged, reworked, reviews, sessions, agent_commits) AS (
9+ SELECT repo_id, substr(created_at, 1, 10), 1, 0, 0, 0, 0, 0, 0 FROM pr
910 WHERE created_at >= ?2 AND created_at < ?3
10− UNION ALL SELECT repo_id, substr(merged_at, 1, 10), 0, 1, agent, 0, 0, 0 FROM pr
11+ UNION ALL SELECT repo_id, substr(merged_at, 1, 10), 0, 1, agent, 0, 0, 0, 0 FROM pr
1112 WHERE status = 'merged' AND merged_at >= ?2 AND merged_at < ?3
12− UNION ALL SELECT c.repo_id, substr(c.created_at, 1, 10), 0, 0, 0, c.verdict IS 'request_changes', 1, 0
13+ UNION ALL SELECT c.repo_id, substr(c.created_at, 1, 10), 0, 0, 0, c.verdict IS 'request_changes', 1, 0, 0
1314 FROM comments c JOIN pr ON pr.repo_id = c.repo_id AND pr.number = c.number
1415 WHERE c.repo_id IN (SELECT value FROM json_each(?1)) AND c.created_at >= ?2 AND c.created_at < ?3
1516 AND c.author_id != pr.owner_id AND c.agent_id IS NULL
1617 AND c.author_id NOT IN ('usr_g1t_agent', 'g1t', 'g1t_policy', 'svc_runner', 'g1t_runner')
17− UNION ALL SELECT repo_id, substr(started_at, 1, 10), 0, 0, 0, 0, 0, 1 FROM sessions
18+ UNION ALL SELECT repo_id, substr(started_at, 1, 10), 0, 0, 0, 0, 0, 1, 0 FROM sessions
1819 WHERE repo_id IN (SELECT value FROM json_each(?1)) AND started_at >= ?2 AND started_at < ?3
20+ UNION ALL SELECT t.repo_id, substr(t.at, 1, 10), 0, 0, 0, 0, 0, 0, COUNT(DISTINCT t.value) FROM session_touches t
21+ WHERE t.repo_id IN (SELECT value FROM json_each(?1)) AND t.kind = 'commit' AND t.at >= ?2 AND t.at < ?3
22+ AND t.session_id NOT IN (SELECT session_id FROM tied)
23+ GROUP BY t.repo_id, substr(t.at, 1, 10)
1924 )
2025 SELECT repo_id, day, SUM(opened) AS opened, SUM(merged) AS merged, SUM(agent_merged) AS agent_merged,
21− SUM(reworked) AS reworked, SUM(reviews) AS reviews, SUM(sessions) AS sessions
26+ SUM(reworked) AS reworked, SUM(reviews) AS reviews, SUM(sessions) AS sessions,
27+ SUM(agent_commits) AS agent_commits
2228 FROM ev GROUP BY repo_id, day ORDER BY day, repo_id
+44−5
99
1010 const IDS: &str = r#"["a"]"#;
1111
12+/// D1 takes at most five terms in one compound SELECT; SQLite allows 500.
13+const D1_COMPOUND_SELECT: i32 = 5;
14+
1215 fn schema() -> Connection {
1316 let db = Connection::open_in_memory().unwrap();
17+ // Refuse what D1 refuses, so a statement these tests pass also runs there.
18+ db.set_limit(rusqlite::limits::Limit::SQLITE_LIMIT_COMPOUND_SELECT, D1_COMPOUND_SELECT);
1419 db.execute_batch(
1520 "CREATE TABLE pulls (id TEXT, repo_id TEXT, number INTEGER, title TEXT DEFAULT 'A change', status TEXT,
1621 item_id TEXT, milestone INTEGER, agent TEXT DEFAULT 'person', author_id TEXT DEFAULT 'usr_ana',
2732 CREATE TABLE commit_statuses (repo_id TEXT, sha TEXT, context TEXT, updated_at TEXT);
2833 CREATE TABLE commit_check_runs (id TEXT, repo_id TEXT, head_sha TEXT, created_at TEXT, updated_at TEXT);
2934 CREATE TABLE session_links (session_id TEXT, repo_id TEXT, kind TEXT, number INTEGER);
30− CREATE TABLE session_touches (session_id TEXT, repo_id TEXT, kind TEXT, value TEXT);
35+ CREATE TABLE session_touches (session_id TEXT, repo_id TEXT, kind TEXT, value TEXT, at TEXT);
3136 CREATE TABLE work_items (id TEXT, workspace TEXT, number INTEGER, title TEXT, state TEXT,
3237 blocked_by TEXT DEFAULT '[]', milestone INTEGER);
3338 CREATE TABLE milestones (workspace TEXT, number INTEGER, title TEXT, state TEXT, due_on TEXT,
195200 INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_4', 'a', 4, '2026-10-06T13:00:00Z');
196201 INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_5', 'a', NULL, '2026-10-06T11:30:00Z');
197202 INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_5b', 'a', NULL, '2026-10-06T10:00:00.500Z');
198− INSERT INTO session_touches VALUES ('ses_5', 'a', 'branch', 'branch-5');
199− INSERT INTO session_touches VALUES ('ses_5b', 'a', 'branch', 'branch-5');
203+ INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_5', 'a', 'branch', 'branch-5');
204+ INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_5b', 'a', 'branch', 'branch-5');
200205 -- 6's branch is on a fork: a push to the same name is not its.
201206 UPDATE pulls SET fork_repo_id = 'fork' WHERE number = 6;
202207 INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_6', 'a', NULL, '2026-10-06T11:00:00Z');
203− INSERT INTO session_touches VALUES ('ses_6', 'a', 'branch', 'branch-6');
208+ INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_6', 'a', 'branch', 'branch-6');
204209 -- A session of another repository with the same number is not its.
205210 INSERT INTO sessions (id, repo_id, pull, started_at) VALUES ('ses_x', 'b', 6, '2026-10-06T11:00:00Z');",
206211 )
735740 -- A subagent tied itself, by a push to 3's branch, counts with that.
736741 INSERT INTO sessions (id, repo_id, parent_id, started_at, cost_usd)
737742 VALUES ('ses_b', 'a', 'ses_r', '2026-10-06T00:00:00Z', 3);
738− INSERT INTO session_touches VALUES ('ses_b', 'a', 'branch', 'branch-3');
743+ INSERT INTO session_touches (session_id, repo_id, kind, value) VALUES ('ses_b', 'a', 'branch', 'branch-3');
739744 -- A session whose cost is not known.
740745 INSERT INTO sessions (id, repo_id, pull, started_at, cost_usd) VALUES ('ses_n', 'a', 3, '2026-10-06T00:00:00Z', NULL);
741746 -- A session tied to nothing counts with nothing.
793798 ]
794799 );
795800 }
801+
802+#[test]
803+fn agent_commits_are_a_sessions_own_outside_any_pull_request() {
804+ let db = schema();
805+ pull(&db, 1, "open", "2026-10-06T00:00:00Z", None, "2026-10-06T00:00:00Z");
806+ db.execute_batch(
807+ "UPDATE pulls SET source_branch = 'feature' WHERE number = 1;
808+ INSERT INTO sessions (id, repo_id, started_at) VALUES ('ses_main', 'a', '2026-10-06T00:00:00Z');
809+ INSERT INTO sessions (id, repo_id, started_at) VALUES ('ses_pr', 'a', '2026-10-06T00:00:00Z');
810+ INSERT INTO session_touches VALUES ('ses_main', 'a', 'commit', 'c1', '2026-10-06T10:00:00Z');
811+ INSERT INTO session_touches VALUES ('ses_main', 'a', 'commit', 'c1', '2026-10-06T10:00:01Z');
812+ INSERT INTO session_touches VALUES ('ses_main', 'a', 'commit', 'c2', '2026-10-01T10:00:00Z');
813+ INSERT INTO session_touches VALUES ('ses_main', 'b', 'commit', 'c9', '2026-10-06T10:00:00Z');
814+ INSERT INTO session_touches VALUES ('ses_pr', 'a', 'branch', 'feature', '2026-10-06T09:00:00Z');
815+ INSERT INTO session_touches VALUES ('ses_pr', 'a', 'commit', 'c3', '2026-10-06T10:00:00Z');",
816+ )
817+ .unwrap();
818+ let found = weeks(&db, "2026-10-18T00:00:00Z");
819+ // c1 once, in its week; c2 in the week before; c3 belongs to a pull request's session; c9 to another repository.
820+ assert_eq!(in_week(&found, "2026-10-05").agent_commits, 1);
821+ assert_eq!(in_week(&found, "2026-09-28").agent_commits, 1);
822+ let window = window("2026-10-18T00:00:00Z");
823+ let days: Vec<SignalDay> = rows(&db, DAYS, &[&IDS, &window.days_from, &window.end]);
824+ let commits: Vec<(&str, u32)> = days
825+ .iter()
826+ .filter(|d| d.agent_commits > 0)
827+ .map(|d| (d.day.as_str(), d.agent_commits))
828+ .collect();
829+ assert_eq!(
830+ commits,
831+ [("2026-10-06", 1)],
832+ "the days read are the last two weeks; c2 is before them"
833+ );
834+}
+27−9
4242 AND NOT (x.status = 'closed' AND COALESCE(x.u < cuts.lo, 0))
4343 ),
4444 -- What each of them saw, as spans [t0, t1] (a moment where t0 = t1).
45−-- CROSS JOIN keeps each read starting from the few candidates.
46−act(pull_id, t0, t1) AS MATERIALIZED (
45+-- CROSS JOIN keeps each read starting from the few candidates. D1 takes
46+-- at most five terms in one compound SELECT, so the sources are read in
47+-- two halves of four and joined.
48+act_a(pull_id, t0, t1) AS MATERIALIZED (
4749 -- Comments of every kind: reviews, and the timeline's events (labels,
4850 -- assignees, review requests, milestones, ready, draft, closed,
4951 -- reopened, base changes, the merge queue, auto-merge, branches deleted
5759 -- Its acceptance checks, from queued to finished.
5860 UNION ALL SELECT k.id, julianday(cr.created_at), julianday(COALESCE(cr.finished_at, cr.created_at))
5961 FROM cand k CROSS JOIN check_runs cr ON cr.pull_id = k.id
62+),
63+act_b(pull_id, t0, t1) AS MATERIALIZED (
6064 -- Statuses and check runs on its head commit, the one commit pulls keep
6165 -- (a status keeps only when it last changed).
62− UNION ALL SELECT k.id, julianday(st.updated_at), julianday(st.updated_at)
66+ SELECT k.id, julianday(st.updated_at), julianday(st.updated_at)
6367 FROM cand k CROSS JOIN commit_statuses st ON st.repo_id = k.repo_id AND st.sha = k.head_commit
6468 UNION ALL SELECT k.id, julianday(ck.created_at), julianday(ck.updated_at)
6569 FROM cand k CROSS JOIN commit_check_runs ck ON ck.repo_id = k.repo_id AND ck.head_sha = k.head_commit
7074 UNION ALL SELECT k.id, julianday(s.started_at), julianday(s.last_seen_at)
7175 FROM cand k CROSS JOIN tied t ON t.pull_id = k.id CROSS JOIN sessions s ON s.parent_id = t.session_id
7276 ),
77+act(pull_id, t0, t1) AS (SELECT * FROM act_a UNION ALL SELECT * FROM act_b),
7378 -- Each week a pull request saw something in the three days before its cut.
7479 busy AS MATERIALIZED (
7580 SELECT DISTINCT a.pull_id, w.start FROM act a JOIN w ON a.t0 < w.cut AND a.t1 >= w.cut - 3
8489 TOTAL(CASE WHEN x.agent AND x.c >= w.lo AND x.c < w.hi THEN x.draft_seconds END) AS agent_draft_seconds,
8590 COUNT(CASE WHEN x.r >= w.lo AND x.r < w.hi THEN 1 END) AS reviewed,
8691 TOTAL(CASE WHEN x.r >= w.lo AND x.r < w.hi THEN MAX(0, (x.r - x.c) * 86400) END) AS review_seconds,
87− 0 AS stalled, 0.0 AS session_cost_usd
92+ 0 AS stalled, 0.0 AS session_cost_usd, 0 AS agent_commits
8893 FROM x CROSS JOIN w
8994 GROUP BY x.repo_id, w.start
9095 ),
9297 -- least three days before, and nothing in the three days before it.
9398 sx AS (
9499 SELECT k.repo_id, w.start, 0 AS merged, 0 AS agent_merged, 0 AS agent_reworked, 0 AS agent_drafted,
95− 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, COUNT(*) AS stalled, 0.0 AS session_cost_usd
100+ 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, COUNT(*) AS stalled, 0.0 AS session_cost_usd, 0 AS agent_commits
96101 FROM cand k CROSS JOIN w LEFT JOIN busy b ON b.pull_id = k.id AND b.start = w.start
97102 WHERE b.pull_id IS NULL AND k.c <= w.cut - 3
98103 AND NOT COALESCE(k.m < w.cut, 0)
104109 sw AS (
105110 SELECT s.repo_id, w.start, 0 AS merged, 0 AS agent_merged, 0 AS agent_reworked, 0 AS agent_drafted,
106111 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, 0 AS stalled,
107− TOTAL(s.cost_usd) AS session_cost_usd
112+ TOTAL(s.cost_usd) AS session_cost_usd, 0 AS agent_commits
108113 FROM sessions s JOIN w ON s.started_at >= w.lo_at AND s.started_at < w.hi_at
109114 WHERE s.repo_id IN (SELECT value FROM json_each(?1))
110115 AND s.started_at >= (SELECT MIN(lo_at) FROM w) AND s.started_at < (SELECT MAX(hi_at) FROM w)
111116 GROUP BY s.repo_id, w.start
117+),
118+-- The commits agent sessions pushed outside any pull request (to a
119+-- branch no pull request is from, the default branch most of all): a
120+-- session's own commits, by when it recorded them, each once.
121+cw AS (
122+ SELECT t.repo_id, w.start, 0 AS merged, 0 AS agent_merged, 0 AS agent_reworked, 0 AS agent_drafted,
123+ 0.0 AS agent_draft_seconds, 0 AS reviewed, 0.0 AS review_seconds, 0 AS stalled, 0.0 AS session_cost_usd,
124+ COUNT(DISTINCT t.value) AS agent_commits
125+ FROM session_touches t JOIN w ON t.at >= w.lo_at AND t.at < w.hi_at
126+ WHERE t.repo_id IN (SELECT value FROM json_each(?1)) AND t.kind = 'commit'
127+ AND t.session_id NOT IN (SELECT session_id FROM tied)
128+ GROUP BY t.repo_id, w.start
112129 )
113130 SELECT repo_id, start, SUM(merged) AS merged, SUM(agent_merged) AS agent_merged,
114131 SUM(agent_reworked) AS agent_reworked, SUM(agent_drafted) AS agent_drafted,
115132 TOTAL(agent_draft_seconds) AS agent_draft_seconds, SUM(reviewed) AS reviewed,
116− TOTAL(review_seconds) AS review_seconds, SUM(stalled) AS stalled, TOTAL(session_cost_usd) AS session_cost_usd
117−FROM (SELECT * FROM pw UNION ALL SELECT * FROM sx UNION ALL SELECT * FROM sw)
133+ TOTAL(review_seconds) AS review_seconds, SUM(stalled) AS stalled, TOTAL(session_cost_usd) AS session_cost_usd,
134+ SUM(agent_commits) AS agent_commits
135+FROM (SELECT * FROM pw UNION ALL SELECT * FROM sx UNION ALL SELECT * FROM sw UNION ALL SELECT * FROM cw)
118136 GROUP BY repo_id, start
119−HAVING SUM(merged) + SUM(agent_drafted) + SUM(reviewed) + SUM(stalled) > 0 OR TOTAL(session_cost_usd) > 0
137+HAVING SUM(merged) + SUM(agent_drafted) + SUM(reviewed) + SUM(stalled) + SUM(agent_commits) > 0 OR TOTAL(session_cost_usd) > 0
120138 ORDER BY start, repo_id

Comments

Sign in to comment.