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.

g1t.sh
467 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.

Merge branch 'worktree-agent-aa68cf6ecc03dffaf'1/**
2 * Coordinating work, as the site shows it: who is on which issue (claims),
3 * which harness sessions are running, what the merge queue found when it
4 * combined changes, and the decision records behind the result. Pure: the
5 * pages and components/coordination.tsx draw what these say.
6 */
7import type {
8 Claim,
9 ClaimState,
10 Decision,
11 DecisionConfidence,
12 EventType,
13 G1tEvent,
14 Harness,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.15 LinkVia,
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'16 SessionState,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.17 TouchKind,
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'18 WorkSession,
19} from "@g1t/contracts";
20
21/** How each harness is named. */
22export const HARNESS_NAMES: Record<Harness, string> = {
23 claude_code: "Claude Code",
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.24 claude_managed_agents: "Claude Managed Agents",
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'25 codex: "Codex",
26 cursor: "Cursor",
27 copilot: "Copilot",
28 other: "Another harness",
29};
30
31export function harnessName(harness: Harness | null | undefined): string | null {
32 return harness ? (HARNESS_NAMES[harness] ?? harness) : null;
33}
34
35/** The coordination events the Work page lists, newest first. */
36export const COORDINATION_TYPES = [
37 "work.claimed",
38 "work.released",
39 "work.claim_expired",
40 "work.handoff_requested",
41 "session.started",
42 "session.ended",
43 "dependency.changed",
44 "overlap.detected",
45 "integration.failed",
46 "integration.passed",
47 "decision.recorded",
48] as const satisfies readonly EventType[];
49
50export type CoordinationType = (typeof COORDINATION_TYPES)[number];
51
52// ── Time ────────────────────────────────────────────────────────────────
53
54const MINUTE = 60_000;
55
56/** "4m", "1h 20m", "2h": a span, for "expires in …". */
57export function span(ms: number): string {
58 const minutes = Math.max(0, Math.floor(ms / MINUTE));
59 if (minutes < 1) return "less than a minute";
60 if (minutes < 60) return `${minutes}m`;
61 const hours = Math.floor(minutes / 60);
62 const rest = minutes % 60;
63 return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
64}
65
66/** When an active claim lapses, in words; null for one that is not active. */
67export function expiresIn(claim: Pick<Claim, "state" | "expiresAt">, now: number): string | null {
68 if (claim.state !== "active") return null;
69 const left = new Date(claim.expiresAt).getTime() - now;
70 return left <= 0 ? "expiring now" : `expires in ${span(left)}`;
71}
72
73/** What became of a claim that is no longer active. */
74export const CLAIM_ENDED: Record<Exclude<ClaimState, "active">, string> = {
75 released: "Released",
76 expired: "Expired",
77 handed_off: "Handed off",
78};
79
80export const SESSION_STATES: Record<SessionState, { label: string; hint: string }> = {
81 active: { label: "Active", hint: "Heard from in the last 15 minutes." },
82 idle: { label: "Idle", hint: "Not heard from for 15 minutes. It may still come back." },
83 ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." },
84};
85
86// ── Live now ────────────────────────────────────────────────────────────
87
88/** One issue being worked on: its claims and the sessions on it. */
89export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] };
90
91/**
92 * Active claims and sessions grouped by the issue they are on, the issue
93 * with the most recent activity first; sessions on no issue last, together.
94 */
95export function liveByIssue(claims: Claim[], sessions: WorkSession[]): { issues: LiveIssue[]; loose: WorkSession[] } {
96 const byIssue = new Map<number, LiveIssue>();
97 const at = (issue: number) => {
98 let group = byIssue.get(issue);
99 if (!group) byIssue.set(issue, (group = { issue, claims: [], sessions: [] }));
100 return group;
101 };
102 for (const claim of claims) at(claim.issue).claims.push(claim);
103 const loose: WorkSession[] = [];
104 for (const session of sessions) {
105 if (session.issue != null) at(session.issue).sessions.push(session);
106 else loose.push(session);
107 }
108 const latest = (group: LiveIssue) =>
109 Math.max(
110 0,
111 ...group.claims.map((claim) => Date.parse(claim.createdAt)),
112 ...group.sessions.map((session) => Date.parse(session.lastSeenAt)),
113 );
114 return { issues: [...byIssue.values()].sort((a, b) => latest(b) - latest(a) || a.issue - b.issue), loose };
115}
116
117// ── Decisions ───────────────────────────────────────────────────────────
118
119export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = {
120 claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" },
121 tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" },
122 approved: { label: "Approved", hint: "A person approved it.", tone: "success" },
123};
124
125export const DECISION_KINDS: Record<Decision["kind"], string> = {
126 proposal: "Proposal",
127 claim: "Claim",
128 integration_failure: "Failed together",
129 integration_passed: "Passed together",
130 fix: "Fix",
131 approval: "Approval",
132 merge: "Merge",
133 note: "Note",
134};
135
136/** The check a decision's evidence names. */
137export type EvidenceCheck = { name: string; conclusion: string; url: string | null };
138
139/** What a decision rests on, read from its evidence as far as it says. */
140export type Evidence = {
141 commit: string | null;
142 check: EvidenceCheck | null;
143 /** The pull requests tested together, in queue order. */
144 combination: number[];
145 /** Those ahead it fails with. */
146 suspects: number[];
147 bisected: boolean;
148};
149
150function numbers(value: unknown): number[] {
151 return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : [];
152}
153
154export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence {
155 const raw = (decision.evidence ?? {}) as Record<string, unknown>;
156 const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null;
157 return {
158 commit: typeof raw.commit === "string" ? raw.commit : null,
159 check:
160 check && typeof check.name === "string"
161 ? {
162 name: check.name,
163 conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure",
164 url: typeof check.url === "string" && check.url ? check.url : null,
165 }
166 : null,
167 combination: numbers(raw.combination),
168 suspects: numbers(raw.suspects),
169 bisected: raw.bisected === true,
170 };
171}
172
173/** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */
174export function checkHref(url: string | null): string | null {
175 if (!url) return null;
176 try {
177 const parsed = new URL(url);
178 if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search;
179 return url;
180 } catch {
181 return url.startsWith("/") ? url : null;
182 }
183}
184
185/**
186 * The integration failure a pull request still answers for: its latest
187 * `integration_failure`, unless it passed in the queue or merged since.
188 * `decisions` in any order.
189 */
190export function standingFailure(decisions: Decision[], pull?: number): Decision | null {
191 const mine = decisions
192 .filter((decision) => pull == null || decision.pull === pull)
193 .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind))
194 .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
195 return mine[0]?.kind === "integration_failure" ? mine[0] : null;
196}
197
198/** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */
199export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } {
200 const after = decisions
201 .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt))
202 .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
203 return {
204 fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.issue === failure.issue)) ?? null,
205 passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null,
206 };
207}
208
209// ── The feed ────────────────────────────────────────────────────────────
210
211/** One line of the coordination feed: what happened, in words, and what it links to. */
212export type FeedLine = {
213 id: string;
214 time: string;
215 type: CoordinationType;
216 /** Who did it, by username; null for g1t itself. */
217 who: string | null;
218 /** The harness it came from, when it says. */
219 harness: Harness | null;
220 /** The sentence, after the name. Numbers in it are written `#N`. */
221 text: string;
222 /** The issue or pull request it is about. */
223 number: number | null;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.224 /** The harness session it is about, by id, for a link to its page. */
225 session?: string;
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'226 tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info";
227};
228
229const list = (all: number[]) => all.map((n) => `#${n}`).join(", ");
230const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
231
232/** A coordination event as a line of the feed, or null for one it does not show. */
233export function feedLine(event: G1tEvent): FeedLine | null {
234 const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null };
235 const data = event.data as Record<string, unknown> & { number?: number };
236 const number = typeof data.number === "number" ? data.number : null;
237 switch (event.type) {
238 case "work.claimed": {
239 const { claim, others = [], tookOver } = event.data;
240 const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : "";
241 return {
242 ...base,
243 type: event.type,
244 who: claim.actor.username,
245 harness: claim.harness,
246 text: `${tookOver ? "took over" : "claimed"} #${claim.issue}${also}`,
247 number: claim.issue,
248 tone: others.length > 0 ? "warn" : "accent",
249 };
250 }
251 case "work.released": {
252 const { claim, reason } = event.data;
253 return {
254 ...base,
255 type: event.type,
256 who: claim.actor.username,
257 harness: claim.harness,
258 text: reason === "issue_closed" ? `no longer holds #${claim.issue}: the issue closed` : `released #${claim.issue}${reason ? `: ${reason}` : ""}`,
259 number: claim.issue,
260 tone: "neutral",
261 };
262 }
263 case "work.claim_expired": {
264 const { claim } = event.data;
265 return { ...base, type: event.type, who: claim.actor.username, harness: claim.harness, text: `let the claim on #${claim.issue} expire`, number: claim.issue, tone: "neutral" };
266 }
267 case "work.handoff_requested": {
268 const { claim, to, note } = event.data;
269 return {
270 ...base,
271 type: event.type,
272 who: claim.actor.username,
273 harness: claim.harness,
274 text: `handed #${claim.issue} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`,
275 number: claim.issue,
276 tone: "info",
277 };
278 }
279 case "session.started":
280 case "session.ended": {
281 const { session } = event.data;
282 const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : "";
283 const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : "";
284 return {
285 ...base,
286 type: event.type,
287 who: session.actor.username,
288 harness: session.harness,
289 text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`,
290 number: session.issue ?? session.pull,
291 tone: event.type === "session.started" ? "accent" : "neutral",
292 };
293 }
294 case "dependency.changed": {
295 const { issue, added, removed, cleared, remaining, mergedPull } = event.data;
296 const parts = [
297 added.length > 0 && `now waits on ${list(added)}`,
298 removed.length > 0 && `no longer waits on ${list(removed)}`,
299 cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`,
300 ].filter(Boolean);
301 // What it still waits on, unless that is only what was just added.
302 const said = remaining.every((n) => added.includes(n)) && parts.length > 0;
303 const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`;
304 return {
305 ...base,
306 type: event.type,
307 who: null,
308 text: `#${issue} ${[...parts, left].filter(Boolean).join("; ")}`,
309 number: issue,
310 tone: remaining.length === 0 ? "success" : "info",
311 };
312 }
313 case "overlap.detected": {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.314 if ("sessionId" in event.data) {
315 const { sessionId, otherActor, files, issue } = event.data;
316 return {
317 ...base,
318 type: event.type,
319 who: otherActor,
320 text: `touched the same ${plural(files.length, "file", "files")} in a session as another session: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`,
321 number: issue ?? number,
322 session: sessionId,
323 tone: "warn",
324 };
325 }
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'326 const { pull, otherPull, files } = event.data;
327 return {
328 ...base,
329 type: event.type,
330 who: null,
331 text: `#${pull} and #${otherPull} change the same ${plural(files.length, "file", "files")}: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`,
332 number: pull,
333 tone: "warn",
334 };
335 }
336 case "integration.failed": {
337 const { pull, combination, check, suspects } = event.data;
338 const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own";
339 const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : "";
340 return { ...base, type: event.type, who: null, text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`, number: pull, tone: "danger" };
341 }
342 case "integration.passed": {
343 const { pull, combination } = event.data;
344 return {
345 ...base,
346 type: event.type,
347 who: null,
348 text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`,
349 number: pull,
350 tone: "success",
351 };
352 }
353 case "decision.recorded": {
354 const { decision } = event.data;
355 const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind;
356 return {
357 ...base,
358 type: event.type,
359 who: decision.actor,
360 text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`,
361 number: decision.pull ?? decision.issue ?? number,
362 tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral",
363 };
364 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.365 // Steps of a session as it goes: for the pages showing it, not the feed.
366 case "session.progressed":
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'367 default:
368 return null;
369 }
370}
371
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.372// ── Session trees ───────────────────────────────────────────────────────
373
374/** A session with its subagents under it, and theirs under them. */
375export type SessionNode = { session: WorkSession; children: SessionNode[] };
376
377/**
378 * Sessions as trees, by `parentId`: the roots in the order given, each
379 * with its subagents in the order given. One whose parent is not in the
380 * list is a root of its own.
381 */
382export function sessionTree(sessions: WorkSession[]): SessionNode[] {
383 const nodes = new Map<string, SessionNode>();
384 for (const session of sessions) if (!nodes.has(session.id)) nodes.set(session.id, { session, children: [] });
385 const roots: SessionNode[] = [];
386 for (const node of nodes.values()) {
387 const parent = node.session.parentId ? nodes.get(node.session.parentId) : undefined;
388 // A parent that is the session itself or under it (a cycle) does not count.
389 if (parent && !contains(node, parent)) parent.children.push(node);
390 else roots.push(node);
391 }
392 return roots;
393}
394
395/** The sessions of several lists, each once (as the first list to have it says), in the order first given. */
396export function mergeSessions(...lists: (WorkSession[] | null | undefined)[]): WorkSession[] {
397 const seen = new Map<string, WorkSession>();
398 for (const list of lists) for (const session of list ?? []) if (!seen.has(session.id)) seen.set(session.id, session);
399 return [...seen.values()];
400}
401
402function contains(node: SessionNode, target: SessionNode): boolean {
403 return node === target || node.children.some((child) => contains(child, target));
404}
405
406/** How many sessions are under a tree's root, at any depth. */
407export function treeSize(node: SessionNode): number {
408 return node.children.reduce((total, child) => total + 1 + treeSize(child), 0);
409}
410
411/** How a session was tied to an issue or pull request, in a few words. */
412export const VIA_LABELS: Record<LinkVia, string> = {
413 attached: "attached",
414 claim: "claimed",
415 branch: "its branch",
416 commit: "its commit",
417 trailer: "commit trailer",
418 opened: "opened it",
419 ref: "named it",
420};
421
422export function viaLabel(via: LinkVia): string {
423 return VIA_LABELS[via] ?? via;
424}
425
426/** What each kind of thing a session touched is called. */
427export const TOUCH_KINDS: Record<TouchKind, string> = {
428 file: "Files",
429 commit: "Commits",
430 branch: "Branches",
431 pull: "Pull requests",
432 issue: "Issues",
433};
434
435/** What was touched, grouped by kind in TOUCH_KINDS' order, each value once, in the order given. */
436export function touchedByKind(touched: { kind: TouchKind; value: string }[]): { kind: TouchKind; values: string[] }[] {
437 return (Object.keys(TOUCH_KINDS) as TouchKind[])
438 .map((kind) => ({ kind, values: [...new Set(touched.filter((one) => one.kind === kind).map((one) => one.value))] }))
439 .filter((group) => group.values.length > 0);
440}
441
442/** "12s", "4m", "1h 20m": how long a session ran, or has run until `now`. */
443export function duration(session: Pick<WorkSession, "startedAt" | "endedAt" | "lastSeenAt" | "state">, now: number): string {
444 const end = session.endedAt ? Date.parse(session.endedAt) : session.state === "ended" ? Date.parse(session.lastSeenAt) : now;
445 const ms = Math.max(0, end - Date.parse(session.startedAt));
446 return ms < MINUTE ? `${Math.floor(ms / 1000)}s` : span(ms);
447}
448
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'449/** Splits text at `#N`, for linking each number. */
450export function numberedParts(text: string): (string | number)[] {
451 const parts: (string | number)[] = [];
452 let last = 0;
453 for (const match of text.matchAll(/#(\d+)\b/g)) {
454 if (match.index > last) parts.push(text.slice(last, match.index));
455 parts.push(Number(match[1]));
456 last = match.index + match[0].length;
457 }
458 if (last < text.length) parts.push(text.slice(last));
459 return parts;
460}
461
462/** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */
463export function parseBlockedBy(text: string): number[] | null {
464 const words = text.split(/[\s,]+/).map((word) => word.trim().replace(/^#/, "")).filter(Boolean);
465 if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null;
466 return [...new Set(words.map(Number))];
467}

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