Skip to content
725 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1/**
2 * The shaping behind mission control's dashboard: why each thing needs the
3 * viewer, what an agent already knows about it, the three tabs, the week
4 * split into what agents landed alone and what a person merged, and the
5 * one sentence under the greeting. Pure, so it is tested on its own; it
6 * imports only types.
7 */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step8import type { AgentRun, CheckStatus, ChangedFile, Confidence, ConfidenceLevel, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms9
10import type { Need } from "./mission";
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.11import { microsOf, money } from "./money.ts";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms12
13const DAY = 24 * 60 * 60 * 1000;
14
15/** Accounts that are g1t's own agents and machinery: `isAgent` in ./mission, kept here so this module imports only types. */
16export function isAgent(name: string | null | undefined): boolean {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent17 return name === "g1t" || (name ?? "").endsWith("-agent");
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms18}
19
20// --- Reasons ----------------------------------------------------------------
21
22/** Why something is waiting on a person, as the chip beside it says. */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step23export type Reason =
24 | "blocking"
25 | "asked_for_you"
26 | "checks_failing"
27 | "outside_guardrails"
28 | "low_confidence"
29 | "needs_review"
30 | "stalled"
31 | "ready_to_merge";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms32
33export const REASON_LABEL: Record<Reason, string> = {
34 blocking: "Blocking",
35 asked_for_you: "Asked for you",
36 checks_failing: "Checks failing",
37 outside_guardrails: "Outside guardrails",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step38 low_confidence: "Low confidence",
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms39 needs_review: "Needs review",
40 stalled: "Stalled",
41 ready_to_merge: "Ready to merge",
42};
43
44/**
45 * The reason a need is shown, from its kind and, for a pull request g1t
46 * stopped seeing through, the sentence it stopped with.
47 */
48export function reasonFor(need: Pick<Need, "kind" | "detail">): Reason {
49 switch (need.kind) {
50 case "limit":
51 case "deploy":
52 case "conflict":
53 return "blocking";
54 case "invitation":
55 case "review":
56 return "asked_for_you";
57 case "checks":
58 return "checks_failing";
59 case "ready":
60 return "ready_to_merge";
61 case "stuck":
Usage, Billing settings and prepaid AI credit; fixes from the UX audit62 case "runner":
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms63 return "stalled";
64 case "stalled":
65 return stallReason(need.detail);
66 }
67}
68
69/** What a pull request's `needs_you` sentence says it is waiting for. */
70export function stallReason(detail: string): Reason {
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step71 // First: its reasons can name checks, caps or approval in passing.
72 if (/confidence in this change is low/i.test(detail)) return "low_confidence";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms73 if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails";
74 if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking";
Fast pages, required checks on the branch, self-hosted runners, honest incidents75 if (/checks? (?:still )?fail|still fails?\b|could not be run|failed in the merge queue/i.test(detail)) return "checks_failing";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms76 if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review";
77 return "stalled";
78}
79
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step80/**
81 * The reason a pull request's need is shown, given how sure g1t is of the
82 * change: one that is ready, or held, with low confidence says so.
83 */
84export function withConfidence(reason: Reason, confidence: Pick<Confidence, "level"> | null | undefined): Reason {
85 if (confidence?.level !== "low") return reason;
86 return reason === "ready_to_merge" || reason === "stalled" || reason === "needs_review" ? "low_confidence" : reason;
87}
88
89const LEVEL: Record<ConfidenceLevel, string> = { low: "Low", medium: "Medium", high: "High" };
90
91/** "Low — tests not added, 3 revisions": a confidence in one line. */
92export function confidenceLine(confidence: Pick<Confidence, "level" | "reasons">): string {
93 return confidence.reasons.length > 0 ? `${LEVEL[confidence.level]} — ${confidence.reasons.join(", ")}` : LEVEL[confidence.level];
94}
95
96/** The ask, for a change held for its low confidence. */
97export function confidenceAsk(confidence: Pick<Confidence, "reasons">): string {
98 const why = confidence.reasons.length > 0 ? `: ${confidence.reasons.join(", ")}` : "";
99 return `The agent finished, but g1t is not sure of the change${why}. Approve it to let it land, or ask for changes.`;
100}
101
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms102/** Why only a person can move it: the callout beside what the agent knows. */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step103export function whyFor(
104 reason: Reason,
105 need: Pick<Need, "kind" | "detail">,
106 confidence?: Pick<Confidence, "reasons" | "uncertainAbout"> | null,
107): string {
108 if (reason === "low_confidence") {
109 const reasons = confidence?.reasons ?? [];
110 const what = reasons.length > 0 ? reasons.join(", ") : "what g1t observed of the change";
111 const unsure =
112 confidence && confidence.uncertainAbout.length > 0 ? ` The agent said it was unsure about ${confidence.uncertainAbout.join("; ")}.` : "";
113 // Held: the repository asks a person first. Otherwise it is ready and waits for a merge anyway.
114 const held =
115 need.kind === "stalled"
116 ? "This repository asks a person before a change like that lands, so nothing merges it until you approve it."
117 : "Look at it before you merge it.";
118 return `g1t rates its confidence in this change low, from ${what}.${unsure} ${held}`;
119 }
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms120 if (need.kind === "limit") return "Agents start nothing new past the workspace's usage limit. Only an owner can raise it or add a card.";
121 if (need.kind === "deploy")
122 return "Production still serves the build before this one. Every push to the default branch builds again, so this fails until what broke is fixed.";
123 if (need.kind === "invitation") return "An invitation is to you. No one else can accept it.";
124 if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict.";
125 if (need.kind === "stuck")
126 return "A running agent has stopped reporting. It may be working on something long, or stuck; a look at its session tells which.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit127 if (need.kind === "runner")
128 return "The job runs only on a self-hosted runner with those labels, and none is connected. It starts as soon as one is; nothing else picks it up.";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms129 switch (reason) {
130 case "outside_guardrails":
131 return "The run reached a limit set in Guardrails. g1t does not lift a cap on its own; a person raises it, then asks for the next step.";
132 case "blocking":
133 return "g1t could not land it and stopped rather than guess. A person decides how it lands, or whether it should.";
134 case "checks_failing":
135 return "The agent revised and the checks still fail, so g1t stopped sending it back instead of looping. Guide it, fix it yourself, or close it.";
136 case "needs_review":
137 return "This repository wants a person's verdict before it lands, and the agent has done what it can without one.";
138 case "ready_to_merge":
139 return "Checks passed and it was approved. This repository lands a change only when a person merges it.";
140 case "stalled":
141 return "The agent stopped and g1t does not start it again on its own. Ask for a review, a revision or a catch-up, or close it.";
142 case "asked_for_you":
143 return "It was put to you by name.";
144 }
145}
146
147/** Reasons where nothing moves until a person acts on it. */
148export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]);
149
150// --- What the agent knows ---------------------------------------------------
151
152export type FactTone = "good" | "warn" | "bad" | null;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step153/** One thing known; a `wide` one takes the whole row and wraps. */
154export type Fact = { label: string; value: string; tone: FactTone; wide?: boolean };
155
156const CONFIDENCE_TONE: Record<ConfidenceLevel, FactTone> = { low: "bad", medium: "warn", high: "good" };
157
158/** How sure g1t is of the change, with its reasons, as a fact. */
159export function confidenceFact(confidence: Pick<Confidence, "level" | "reasons">): Fact {
160 return { label: "Confidence", value: confidenceLine(confidence), tone: CONFIDENCE_TONE[confidence.level], wide: true };
161}
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms162
Fast pages, required checks on the branch, self-hosted runners, honest incidents163/** The stages a pull request reaches only once its required checks have passed. */
164const PAST_CHECKS = new Set(["reviewing", "catching_up", "answering", "queued", "ready"]);
165
166/**
167 * How the checks the default branch requires stand on a pull request, from
168 * what a list knows without opening it: the merge queue taking it out, and
169 * for one g1t sees through, the stage it is at and what that stage says.
170 */
171export function checksFact(
172 checkStatus: CheckStatus | null,
173 lifecycle?: Pick<Lifecycle, "stage" | "detail"> | null,
174): Fact {
175 const fact = (value: string, tone: FactTone): Fact => ({ label: "Required checks", value, tone });
176 if (checkStatus === "failed") return fact("Failed in the merge queue", "bad");
177 if (!lifecycle?.stage) return fact(checkStatus === "passed" ? "Passing" : "Not known yet", checkStatus === "passed" ? "good" : null);
178 const detail = lifecycle.detail ?? "";
179 // "CI / pull_request failed", "The required check CI still fails ...".
180 const failing = /\b(?:failed|still fails?)\b/i.test(detail) && !/review|asked for changes|merge queue/i.test(detail);
181 if (lifecycle.stage === "checking") return /to report/i.test(detail) ? fact("Not reported yet", "warn") : fact("Running", null);
182 if (lifecycle.stage === "working") return fact("Not run yet", null);
183 if ((lifecycle.stage === "revising" || lifecycle.stage === "needs_you") && failing) return fact("Failing", "bad");
184 if (PAST_CHECKS.has(lifecycle.stage) || lifecycle.stage === "revising" || lifecycle.stage === "needs_you") return fact("Passing", "good");
185 return fact("Not known yet", null);
186}
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms187
188/** Test files, by the names test runners look for. */
189export function isTestFile(path: string): boolean {
A file named test.js or spec.rb counts as a test: confidence no longer says tests not added when they were190 return (
191 /(^|\/)(__tests__|tests?|spec)\//i.test(path) ||
192 /[._-](test|spec)\.[a-z0-9]+$/i.test(path) ||
193 /_test\.(go|rs|py)$/i.test(path) ||
194 // A file named for what it is: test.js, tests.py, spec.rb.
195 /(^|\/)(tests?|specs?)\.[a-z0-9]+$/i.test(path)
196 );
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms197}
198
199const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
200
201/**
202 * What is known about a pull request without opening it: its checks, how
203 * much it changes, the tests it touches, how often the agent was sent back,
204 * and what its runs cost. Only what is known is listed.
205 */
206export function pullFacts(input: {
207 checkStatus: CheckStatus | null;
208 files: ChangedFile[];
Fast pages, required checks on the branch, self-hosted runners, honest incidents209 lifecycle?: (Pick<Lifecycle, "revisions"> & Partial<Pick<Lifecycle, "stage" | "detail">>) | null;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms210 runs?: Pick<AgentRun, "costUsd" | "kind">[];
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step211 confidence?: Pick<Confidence, "level" | "reasons"> | null;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms212}): Fact[] {
213 const facts: Fact[] = [];
Fast pages, required checks on the branch, self-hosted runners, honest incidents214 facts.push(
215 checksFact(input.checkStatus, input.lifecycle?.stage ? { stage: input.lifecycle.stage, detail: input.lifecycle.detail ?? "" } : null),
216 );
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms217 if (input.files.length > 0) {
218 const added = input.files.reduce((sum, f) => sum + f.additions, 0);
219 const removed = input.files.reduce((sum, f) => sum + f.deletions, 0);
220 facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null });
221 facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null });
222 const tests = input.files.filter((f) => isTestFile(f.path)).length;
223 facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" });
224 }
225 if (input.lifecycle && input.lifecycle.revisions > 0) {
226 facts.push({
227 label: "Sent back",
228 value: plural(input.lifecycle.revisions, "time"),
229 tone: input.lifecycle.revisions > 1 ? "warn" : null,
230 });
231 }
232 const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck");
233 if (agentRuns.length > 0) {
234 const costs = agentRuns.filter((run) => run.costUsd != null);
235 const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
236 facts.push({
237 label: "Agent runs",
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.238 value: costs.length > 0 ? `${agentRuns.length} · ${money(microsOf(cost))}` : String(agentRuns.length),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms239 tone: null,
240 });
241 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step242 if (input.confidence) facts.push(confidenceFact(input.confidence));
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms243 return facts;
244}
245
246// --- Rows -------------------------------------------------------------------
247
248/** Someone or something on a row: an agent, a person, or g1t itself. */
249export type Who = { name: string; agent: boolean };
250
251export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null);
252
253/** A form on mission control that acts without leaving it. */
254export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string };
255
256/** One thing that needs the viewer, with what is known about it. */
257export type NeedRow = {
258 key: string;
259 reason: Reason;
260 repo: RepoPath | null;
261 /** "#41", when it is an issue or pull request. */
262 ref: string | null;
263 title: string;
264 /** The ask, in one sentence. */
265 ask: string;
266 /** Who is waiting: the agent on it, or the person who asked. */
267 by: Who | null;
268 /** The person who started it, when an agent did the work. */
269 for: string | null;
270 at: number;
271 to: string;
272 open: string;
273 facts: Fact[];
274 why: string;
275 quick: QuickAction | null;
276 /** Somewhere else to act, when that is the next step. */
277 link: { label: string; to: string } | null;
278};
279
280/** The agents' stages a pull request can wait in, as their chips say them. */
281export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = {
282 working: "Working",
283 checking: "Checking",
284 reviewing: "Reviewing",
285 revising: "Revising",
286 catching_up: "Catching up",
287 answering: "Answering",
288 queued: "In queue",
289};
290
291/** A run's kind, as its chip says it. */
292export const RUN_LABEL: Record<RunKind, string> = {
293 implement: "Working",
294 revise: "Revising",
295 review: "Reviewing",
296 answer: "Answering",
297 update: "Catching up",
298 plan: "Planning",
299 checks: "Checking",
300 queue: "In queue",
301 mergecheck: "Checking",
302};
303
304/** Something agents are doing, which the viewer can leave to them. */
305export type WaitingRow = {
306 key: string;
307 repo: RepoPath;
308 ref: string | null;
309 title: string;
310 chip: string;
311 detail: string;
312 by: Who | null;
313 at: number;
314 to: string;
315 /** Whether a run is going on it right now. */
316 live: boolean;
317 /** The run going on it, to watch. */
318 run: string | null;
319 facts: Fact[];
320};
321
322const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase();
323
324/**
325 * What is in agents' hands: pull requests in an agent's stage, runs going
326 * now, and drafts agents are still making. Each once, and nothing that is
327 * already waiting on the viewer.
328 */
329export function waitingRows(input: {
330 active: {
331 pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] };
332 lifecycle: Lifecycle | null;
333 repo: RepoPath;
334 }[];
335 live: Pick<
336 AgentRun,
337 "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt"
338 >[];
339 drafts: {
340 number: number;
341 title: string;
342 agent: string;
343 updatedAt: string;
344 checkStatus: CheckStatus | null;
345 files: ChangedFile[];
346 repo: RepoPath;
347 }[];
348 needKeys: ReadonlySet<string>;
349}): WaitingRow[] {
350 const rows = new Map<string, WaitingRow>();
351 for (const { pull, lifecycle, repo } of input.active) {
352 if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue;
353 const key = pathKey(repo, pull.number);
354 if (input.needKeys.has(key)) continue;
355 rows.set(key, {
356 key,
357 repo,
358 ref: `#${pull.number}`,
359 title: pull.title,
360 chip: STAGE_CHIP[lifecycle.stage],
361 detail: lifecycle.detail,
362 by: who(pull.agent),
363 at: Date.parse(pull.updatedAt),
364 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
365 live: false,
366 run: null,
367 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }),
368 });
369 }
370 for (const run of input.live) {
371 const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`;
372 if (input.needKeys.has(key)) continue;
373 const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`;
374 const existing = rows.get(key);
375 const started = Date.parse(run.startedAt ?? run.createdAt);
376 const runFacts: Fact[] = [
377 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.378 ...(run.costUsd != null ? [{ label: "Cost so far", value: money(microsOf(run.costUsd)), tone: null }] : []),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms379 ];
380 if (existing) {
381 existing.live = true;
382 existing.run = runTo;
383 if (run.step) existing.detail = run.step;
384 existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")];
385 existing.at = Math.max(existing.at, Date.parse(run.updatedAt));
386 continue;
387 }
388 rows.set(key, {
389 key,
390 repo: run.repo,
391 ref: run.number != null ? `#${run.number}` : null,
392 title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`,
393 chip: RUN_LABEL[run.kind],
394 detail: run.step ?? "Starting.",
395 by: who(run.agent),
396 at: Number.isFinite(started) ? started : Date.parse(run.updatedAt),
397 to: runTo,
398 live: true,
399 run: runTo,
400 facts: runFacts,
401 });
402 }
403 for (const draft of input.drafts) {
404 const key = pathKey(draft.repo, draft.number);
405 if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue;
406 rows.set(key, {
407 key,
408 repo: draft.repo,
409 ref: `#${draft.number}`,
410 title: draft.title,
411 chip: "Working",
412 detail: `${draft.agent} is making the change.`,
413 by: who(draft.agent),
414 at: Date.parse(draft.updatedAt),
415 to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`,
416 live: false,
417 run: null,
418 facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }),
419 });
420 }
421 // Running now first, then what moved most recently.
422 return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at);
423}
424
425/** The key a need is known by when matching it to agents' work. */
426export const needPathKey = pathKey;
427
428// --- Landed -----------------------------------------------------------------
429
Mission control counts people's direct pushes to the default branch430/**
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)431 * The accounts g1t itself acts as on the event log: its agent, and the
432 * policy that merges and pushes for it. An event's `actor` is an account
433 * id, so this is what tells g1t apart, whatever name a commit carries.
434 */
435export const G1T_ACCOUNT_IDS: ReadonlySet<string> = new Set(["usr_g1t_agent", "g1t_policy"]);
436
437/** A `git.push` from the log, as far as placing its commits needs it. */
438export type PushRecord = {
439 time: string;
440 /** The account that pushed; null when the log does not say. */
441 actor?: string | null;
442 data: { after: string; before?: string; causedByJob?: string };
443};
444
445/**
446 * Whether a person pushed: not g1t or one of its agents (by the account
447 * that signed in to push), and not a workflow job's own token.
Mission control counts people's direct pushes to the default branch448 */
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)449export function pushedByPerson(push: Pick<PushRecord, "actor" | "data">): boolean {
450 if (push.data.causedByJob) return false;
451 return !(push.actor && G1T_ACCOUNT_IDS.has(push.actor));
452}
453
454/**
455 * The commits a person's push to the default branch brought, from the
456 * history at its `after` (newest first) back to its `before`: their own,
457 * not merges (a pull request landing) and not g1t's (`byG1t`: a commit
458 * g1t wrote, told by its author address, which a person may fast-forward
459 * onto the branch). A push whose `before` is not in what was read gives
460 * what was read. Who pushed is the caller's to decide (`pushedByPerson`);
461 * a commit's author name says nothing about it.
462 */
463export function pushedCommits<C extends { hash: string; parents: string[] }>(
Mission control counts people's direct pushes to the default branch464 history: C[],
465 before: string | undefined,
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)466 byG1t: (commit: C) => boolean = () => false,
Mission control counts people's direct pushes to the default branch467): C[] {
468 const end = before ? history.findIndex((commit) => commit.hash === before) : -1;
469 const brought = end === -1 ? history : history.slice(0, end);
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)470 return brought.filter((commit) => commit.parents.length <= 1 && !byG1t(commit));
Mission control counts people's direct pushes to the default branch471}
472
Merge branch 'main' into actions-toolkit-oidc-artifacts473/**
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)474 * Each commit of `history` (newest first) a person's push in `pushes`
475 * (newest first) brought, at that push's time. A commit pushed twice
476 * (after a force push, say) counts once, at its first landing; a push
477 * whose `after` is no longer in the history (rewritten) brings nothing,
478 * and g1t's own pushes bring nothing here: pull requests count those.
Merge branch 'main' into actions-toolkit-oidc-artifacts479 */
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)480export function placePushes<C extends { hash: string; parents: string[] }>(
Merge branch 'main' into actions-toolkit-oidc-artifacts481 history: C[],
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)482 pushes: PushRecord[],
483 byG1t: (commit: C) => boolean = () => false,
Merge branch 'main' into actions-toolkit-oidc-artifacts484): { hash: string; at: string }[] {
485 const index = new Map(history.map((commit, i) => [commit.hash, i]));
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)486 // Each commit's first landing: the time, or null when g1t landed it.
487 const seen = new Map<string, string | null>();
Merge branch 'main' into actions-toolkit-oidc-artifacts488 for (const push of [...pushes].reverse()) {
489 const start = index.get(push.data.after);
490 if (start === undefined) continue;
491 const end = push.data.before ? index.get(push.data.before) : undefined;
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)492 const range = history.slice(start, end ?? history.length);
493 const at = pushedByPerson(push) ? push.time : null;
494 for (const commit of pushedCommits(range, undefined, byG1t)) {
495 if (!seen.has(commit.hash)) seen.set(commit.hash, at);
Merge branch 'main' into actions-toolkit-oidc-artifacts496 }
497 }
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)498 return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : []));
Merge branch 'main' into actions-toolkit-oidc-artifacts499}
500
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails501/**
502 * Whether a read of the default branch reaches back far enough to place
503 * every push, newest first: it holds the oldest push's `before`, or it is
504 * the whole branch. A shorter read than `asked` is the whole branch.
505 */
506export function historyCovers(history: { hash: string }[], pushes: PushRecord[], asked: number): boolean {
507 if (history.length < asked) return true;
508 const oldest = pushes.at(-1)?.data.before;
509 return oldest != null && history.some((commit) => commit.hash === oldest);
510}
511
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)512/** A merged pull request, as the week counts it. */
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms513export type Merged = {
514 repo: RepoPath;
515 number: number;
516 title: string;
517 agent: string;
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar518 /** Whether an agent wrote it (its author is g1t or another agent), not a person. */
519 authoredByAgent: boolean;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms520 mergedBy: string | null;
521 mergedAt: string;
522 files: ChangedFile[];
523};
524
525/**
526 * Whether a change landed without a person: g1t merged it, by auto-merge or
527 * from the merge queue, rather than someone pressing merge.
528 */
529export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean {
530 return change.mergedBy == null || isAgent(change.mergedBy);
531}
532
533/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
534export function dayKey(at: number, timeZone: string | null): string {
535 try {
536 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
537 } catch {
538 return new Date(at).toISOString().slice(0, 10);
539 }
540}
541
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar542/**
543 * One day's changes by who did the work: `agents`, written by an agent and
544 * landed without a person; `assisted`, written by an agent and merged by a
545 * person; `people`, written by a person, however it landed.
546 */
547export type WeekDay = { key: string; label: string; agents: number; assisted: number; people: number };
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms548
549export type Week = {
550 days: WeekDay[];
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar551 /** Changes landed in the last seven days. */
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms552 total: number;
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar553 /** Of those, agents' changes that landed without a person. */
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms554 byAgents: number;
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar555 /** Agents' changes, with a person or without. */
556 agentChanges: number;
557 /** People's own changes. */
558 people: number;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms559 /** The seven days before, or null when the lists read do not reach back that far. */
560 previous: number | null;
561};
562
563/**
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar564 * The last seven days in the viewer's zone, oldest first, each split by who
565 * did the work (see `WeekDay`), and the week before as one number.
566 * `complete` says whether what was read reaches back two weeks; when it
567 * does not, the week before is not guessed.
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms568 */
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar569export function weekOf(
570 changes: Pick<Merged, "mergedAt" | "mergedBy" | "authoredByAgent">[],
571 now: number,
572 timeZone: string | null,
573 complete = true,
574): Week {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms575 const days: WeekDay[] = [];
576 const index = new Map<string, number>();
577 for (let back = 6; back >= 0; back -= 1) {
578 const at = now - back * DAY;
579 const key = dayKey(at, timeZone);
580 if (index.has(key)) continue;
581 index.set(key, days.length);
582 let label: string;
583 try {
584 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
585 } catch {
586 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
587 }
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar588 days.push({ key, label, agents: 0, assisted: 0, people: 0 });
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms589 }
590 const oldest = days[0]?.key ?? "";
591 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
592 let previous = 0;
593 for (const change of changes) {
594 const at = Date.parse(change.mergedAt);
595 if (!Number.isFinite(at) || at > now) continue;
596 const key = dayKey(at, timeZone);
597 const slot = index.get(key);
598 if (slot != null) {
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar599 if (!change.authoredByAgent) days[slot].people += 1;
600 else if (landedByAgents(change)) days[slot].agents += 1;
601 else days[slot].assisted += 1;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms602 } else if (key < oldest && key >= twoWeeks) {
603 previous += 1;
604 }
605 }
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar606 const sum = (pick: (day: WeekDay) => number) => days.reduce((total, day) => total + pick(day), 0);
607 const byAgents = sum((day) => day.agents);
608 const agentChanges = byAgents + sum((day) => day.assisted);
609 const people = sum((day) => day.people);
610 return { days, total: agentChanges + people, byAgents, agentChanges, people, previous: complete ? previous : null };
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms611}
612
613/** The change from one number to another, as a share; null from nothing. */
614export function change(current: number, previous: number | null): number | null {
615 if (previous == null || previous === 0) return null;
616 return (current - previous) / previous;
617}
618
619/** "+18%", "−5%", "0%". */
620export function signedPercent(share: number): string {
621 const n = Math.round(share * 100);
622 return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
623}
624
625/**
626 * Whether the merged pull requests read for one repository reach back to
627 * `since`: the list is not full, or its oldest goes back that far.
628 */
629export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
630 if (list.length < page) return true;
631 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
632 return oldest <= since;
633}
634
635/** One change that landed, as the third tab lists it. */
636export type LandedRow = {
637 key: string;
638 repo: RepoPath;
639 ref: string;
640 title: string;
641 by: Who | null;
642 agent: string;
643 byAgents: boolean;
644 at: number;
645 to: string;
646 facts: Fact[];
647};
648
649/** What landed on the viewer's calendar day, newest first. */
650export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
651 const today = dayKey(now, timeZone);
652 return changes
653 .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
654 .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
655 .map((change) => ({
656 key: pathKey(change.repo, change.number),
657 repo: change.repo,
658 ref: `#${change.number}`,
659 title: change.title,
660 by: who(change.mergedBy ?? "g1t"),
661 agent: change.agent,
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar662 byAgents: change.authoredByAgent && landedByAgents(change),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms663 at: Date.parse(change.mergedAt),
664 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
Fast pages, required checks on the branch, self-hosted runners, honest incidents665 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms666 }));
667}
668
669// --- Tabs and sorting -------------------------------------------------------
670
671export type Tab = "needs" | "waiting" | "landed";
672export type Sort = "impact" | "newest";
673
674export const TABS: Tab[] = ["needs", "waiting", "landed"];
675
676export function parseTab(value: string | null): Tab | null {
677 return value === "needs" || value === "waiting" || value === "landed" ? value : null;
678}
679
680export function parseSort(value: string | null): Sort {
681 return value === "newest" ? "newest" : "impact";
682}
683
684/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
685export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
686 return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
687}
688
689// --- The summary ------------------------------------------------------------
690
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar691/**
692 * The sentence under the greeting: the week, honestly, in one line. What
693 * agents landed alone is counted against the agents' own changes, never
694 * against what people wrote.
695 */
696export function summaryLine(input: {
697 total: number;
698 byAgents: number;
699 agentChanges: number;
700 people: number;
701 live: number;
702 needs: number;
703}): string {
704 const { total, byAgents, agentChanges, people, live, needs } = input;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms705 if (total > 0) {
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar706 const theirs = people > 0 ? `, and people landed ${plural(people, "change")} of their own` : "";
707 if (agentChanges === 0) return `People landed ${plural(people, "change")} this week; none were agents'.`;
708 if (byAgents === agentChanges) return `Agents landed all ${plural(agentChanges, "change")} of theirs this week without you${theirs}.`;
709 if (byAgents === 0) return `Agents made ${plural(agentChanges, "change")} this week, each merged by a person${theirs}.`;
710 return `Agents landed ${byAgents} of their ${plural(agentChanges, "change")} this week without you${theirs}.`;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms711 }
712 if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
713 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent714 return "Nothing has landed this week yet. Assign an issue to g1t and it starts in seconds.";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms715}
716
717/** "Monday, Oct 5", in the viewer's zone. */
718export function dateLine(now: number, timeZone: string | null): string {
719 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
720 try {
721 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
722 } catch {
723 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
724 }
725}

This file's history is long; its oldest lines are credited to the oldest commit read.