Skip to content
550 linesCodeBlameRaw
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,
15 LinkVia,
16 SessionState,
17 TouchKind,
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",
24 claude_managed_agents: "Claude Managed Agents",
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// ── Session titles ──────────────────────────────────────────────────────
87
88/** The most of a prompt's intent that titles a session. */
89export const TITLE_LIMIT = 80;
90
91/**
92 * What a prompt is for, as a session's title: its first line or sentence,
93 * without code blocks or markdown, whitespace collapsed, clipped at
94 * TITLE_LIMIT on a word with "…". Null for a slash command, a turn the
95 * harness starts itself, or nothing to say. The Claude Code plugin
96 * (plugins/claude-code/hooks/g1t.mjs) titles its sessions the same way.
97 */
98export function intentTitle(prompt: string | null | undefined): string | null {
99 let text = String(prompt ?? "").replace(/^\uFEFF/, "");
100 if (/^\s*\//.test(text) || /^\s*<[a-z][\w-]*>/i.test(text)) return null;
101 text = text
102 .replace(/```[\s\S]*?(```|$)/g, "\n")
103 .replace(/~~~[\s\S]*?(~~~|$)/g, "\n")
104 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
105 .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
106 .replace(/`([^`]*)`/g, "$1");
107 const line = text
108 .split(/\r?\n/)
109 .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").trim())
110 .find((l) => /[\p{L}\p{N}]/u.test(l));
111 if (!line) return null;
112 let intent = line.replace(/\s+/g, " ").split(/(?<=[.!?])\s+(?=\S)/)[0].trim();
113 if (intent.length > TITLE_LIMIT) {
114 const cut = intent.slice(0, TITLE_LIMIT - 1);
115 const space = cut.lastIndexOf(" ");
116 intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`;
117 } else {
118 intent = intent.replace(/[\s,;:.]+$/, "");
119 }
120 if (intent.length < 2) return null;
121 return intent[0].toLocaleUpperCase() + intent.slice(1);
122}
123
124/**
125 * What a session is called, never nothing: its title, else what its first
126 * prompt was for, else the branch it is on, else "Untitled session".
127 * `firstPrompt` stands in for the session's own when given.
128 */
129export function sessionTitle(
130 session: Pick<WorkSession, "title" | "branch" | "firstPrompt">,
131 firstPrompt: string | null | undefined = session.firstPrompt,
132): string {
133 const title = session.title?.trim();
134 if (title) return title;
135 const intent = intentTitle(firstPrompt);
136 if (intent) return intent;
137 return session.branch ? `Session on ${session.branch}` : "Untitled session";
138}
139
140// ── Live now ────────────────────────────────────────────────────────────
141
142/** One issue being worked on: its claims and the sessions on it. */
143export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] };
144
145/** Sessions on no issue yet, together: on one pull request, on one branch of their own, or on neither. */
146export type LiveGroup =
147 | { kind: "pull"; pull: number; sessions: WorkSession[] }
148 | { kind: "branch"; branch: string; sessions: WorkSession[] }
149 | { kind: "none"; sessions: WorkSession[] };
150
151const latestSeen = (sessions: WorkSession[]) => Math.max(0, ...sessions.map((session) => Date.parse(session.lastSeenAt)));
152
153/**
154 * Active claims and sessions grouped by the issue they are on, the issue
155 * with the most recent activity first. Sessions on no issue are grouped
156 * after them (`groups`): by their pull request, else by their branch when
157 * it is not `defaultBranch`, the most recently heard from first; the rest,
158 * on no branch of their own, last together. `loose` is every session on no
159 * issue, as the groups hold them.
160 */
161export function liveByIssue(
162 claims: Claim[],
163 sessions: WorkSession[],
164 defaultBranch?: string | null,
165): { issues: LiveIssue[]; groups: LiveGroup[]; loose: WorkSession[] } {
166 const byIssue = new Map<number, LiveIssue>();
167 const at = (issue: number) => {
168 let group = byIssue.get(issue);
169 if (!group) byIssue.set(issue, (group = { issue, claims: [], sessions: [] }));
170 return group;
171 };
172 for (const claim of claims) at(claim.issue).claims.push(claim);
173 const byPull = new Map<number, LiveGroup & { kind: "pull" }>();
174 const byBranch = new Map<string, LiveGroup & { kind: "branch" }>();
175 const rest: WorkSession[] = [];
176 for (const session of sessions) {
177 if (session.issue != null) at(session.issue).sessions.push(session);
178 else if (session.pull != null) {
179 const group = byPull.get(session.pull) ?? { kind: "pull" as const, pull: session.pull, sessions: [] };
180 byPull.set(session.pull, group);
181 group.sessions.push(session);
182 } else if (session.branch && session.branch !== defaultBranch) {
183 const group = byBranch.get(session.branch) ?? { kind: "branch" as const, branch: session.branch, sessions: [] };
184 byBranch.set(session.branch, group);
185 group.sessions.push(session);
186 } else rest.push(session);
187 }
188 const latest = (group: LiveIssue) => Math.max(latestSeen(group.sessions), ...group.claims.map((claim) => Date.parse(claim.createdAt)));
189 const recent = (a: LiveGroup, b: LiveGroup) => latestSeen(b.sessions) - latestSeen(a.sessions);
190 const groups: LiveGroup[] = [...byPull.values(), ...byBranch.values()].sort(recent);
191 if (rest.length > 0) groups.push({ kind: "none", sessions: rest });
192 for (const group of groups) group.sessions.sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt));
193 return {
194 issues: [...byIssue.values()].sort((a, b) => latest(b) - latest(a) || a.issue - b.issue),
195 groups,
196 loose: groups.flatMap((group) => group.sessions),
197 };
198}
199
200// ── Decisions ───────────────────────────────────────────────────────────
201
202export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = {
203 claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" },
204 tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" },
205 approved: { label: "Approved", hint: "A person approved it.", tone: "success" },
206};
207
208export const DECISION_KINDS: Record<Decision["kind"], string> = {
209 proposal: "Proposal",
210 claim: "Claim",
211 integration_failure: "Failed together",
212 integration_passed: "Passed together",
213 fix: "Fix",
214 approval: "Approval",
215 merge: "Merge",
216 note: "Note",
217};
218
219/** The check a decision's evidence names. */
220export type EvidenceCheck = { name: string; conclusion: string; url: string | null };
221
222/** What a decision rests on, read from its evidence as far as it says. */
223export type Evidence = {
224 commit: string | null;
225 check: EvidenceCheck | null;
226 /** The pull requests tested together, in queue order. */
227 combination: number[];
228 /** Those ahead it fails with. */
229 suspects: number[];
230 bisected: boolean;
231};
232
233function numbers(value: unknown): number[] {
234 return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : [];
235}
236
237export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence {
238 const raw = (decision.evidence ?? {}) as Record<string, unknown>;
239 const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null;
240 return {
241 commit: typeof raw.commit === "string" ? raw.commit : null,
242 check:
243 check && typeof check.name === "string"
244 ? {
245 name: check.name,
246 conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure",
247 url: typeof check.url === "string" && check.url ? check.url : null,
248 }
249 : null,
250 combination: numbers(raw.combination),
251 suspects: numbers(raw.suspects),
252 bisected: raw.bisected === true,
253 };
254}
255
256/** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */
257export function checkHref(url: string | null): string | null {
258 if (!url) return null;
259 try {
260 const parsed = new URL(url);
261 if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search;
262 return url;
263 } catch {
264 return url.startsWith("/") ? url : null;
265 }
266}
267
268/**
269 * The integration failure a pull request still answers for: its latest
270 * `integration_failure`, unless it passed in the queue or merged since.
271 * `decisions` in any order.
272 */
273export function standingFailure(decisions: Decision[], pull?: number): Decision | null {
274 const mine = decisions
275 .filter((decision) => pull == null || decision.pull === pull)
276 .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind))
277 .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
278 return mine[0]?.kind === "integration_failure" ? mine[0] : null;
279}
280
281/** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */
282export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } {
283 const after = decisions
284 .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt))
285 .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
286 return {
287 fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.issue === failure.issue)) ?? null,
288 passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null,
289 };
290}
291
292// ── The feed ────────────────────────────────────────────────────────────
293
294/** One line of the coordination feed: what happened, in words, and what it links to. */
295export type FeedLine = {
296 id: string;
297 time: string;
298 type: CoordinationType;
299 /** Who did it, by username; null for g1t itself. */
300 who: string | null;
301 /** The harness it came from, when it says. */
302 harness: Harness | null;
303 /** The sentence, after the name. Numbers in it are written `#N`. */
304 text: string;
305 /** The issue or pull request it is about. */
306 number: number | null;
307 /** The harness session it is about, by id, for a link to its page. */
308 session?: string;
309 tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info";
310};
311
312const list = (all: number[]) => all.map((n) => `#${n}`).join(", ");
313const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
314
315/** A coordination event as a line of the feed, or null for one it does not show. */
316export function feedLine(event: G1tEvent): FeedLine | null {
317 const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null };
318 const data = event.data as Record<string, unknown> & { number?: number };
319 const number = typeof data.number === "number" ? data.number : null;
320 switch (event.type) {
321 case "work.claimed": {
322 const { claim, others = [], tookOver } = event.data;
323 const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : "";
324 return {
325 ...base,
326 type: event.type,
327 who: claim.actor.username,
328 harness: claim.harness,
329 text: `${tookOver ? "took over" : "claimed"} #${claim.issue}${also}`,
330 number: claim.issue,
331 tone: others.length > 0 ? "warn" : "accent",
332 };
333 }
334 case "work.released": {
335 const { claim, reason } = event.data;
336 return {
337 ...base,
338 type: event.type,
339 who: claim.actor.username,
340 harness: claim.harness,
341 text: reason === "issue_closed" ? `no longer holds #${claim.issue}: the issue closed` : `released #${claim.issue}${reason ? `: ${reason}` : ""}`,
342 number: claim.issue,
343 tone: "neutral",
344 };
345 }
346 case "work.claim_expired": {
347 const { claim } = event.data;
348 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" };
349 }
350 case "work.handoff_requested": {
351 const { claim, to, note } = event.data;
352 return {
353 ...base,
354 type: event.type,
355 who: claim.actor.username,
356 harness: claim.harness,
357 text: `handed #${claim.issue} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`,
358 number: claim.issue,
359 tone: "info",
360 };
361 }
362 case "session.started":
363 case "session.ended": {
364 const { session } = event.data;
365 const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : "";
366 const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : "";
367 return {
368 ...base,
369 type: event.type,
370 who: session.actor.username,
371 harness: session.harness,
372 text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`,
373 number: session.issue ?? session.pull,
374 tone: event.type === "session.started" ? "accent" : "neutral",
375 };
376 }
377 case "dependency.changed": {
378 const { issue, added, removed, cleared, remaining, mergedPull } = event.data;
379 const parts = [
380 added.length > 0 && `now waits on ${list(added)}`,
381 removed.length > 0 && `no longer waits on ${list(removed)}`,
382 cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`,
383 ].filter(Boolean);
384 // What it still waits on, unless that is only what was just added.
385 const said = remaining.every((n) => added.includes(n)) && parts.length > 0;
386 const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`;
387 return {
388 ...base,
389 type: event.type,
390 who: null,
391 text: `#${issue} ${[...parts, left].filter(Boolean).join("; ")}`,
392 number: issue,
393 tone: remaining.length === 0 ? "success" : "info",
394 };
395 }
396 case "overlap.detected": {
397 if ("sessionId" in event.data) {
398 const { sessionId, otherActor, files, issue } = event.data;
399 return {
400 ...base,
401 type: event.type,
402 who: otherActor,
403 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` : ""}`,
404 number: issue ?? number,
405 session: sessionId,
406 tone: "warn",
407 };
408 }
409 const { pull, otherPull, files } = event.data;
410 return {
411 ...base,
412 type: event.type,
413 who: null,
414 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` : ""}`,
415 number: pull,
416 tone: "warn",
417 };
418 }
419 case "integration.failed": {
420 const { pull, combination, check, suspects } = event.data;
421 const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own";
422 const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : "";
423 return { ...base, type: event.type, who: null, text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`, number: pull, tone: "danger" };
424 }
425 case "integration.passed": {
426 const { pull, combination } = event.data;
427 return {
428 ...base,
429 type: event.type,
430 who: null,
431 text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`,
432 number: pull,
433 tone: "success",
434 };
435 }
436 case "decision.recorded": {
437 const { decision } = event.data;
438 const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind;
439 return {
440 ...base,
441 type: event.type,
442 who: decision.actor,
443 text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`,
444 number: decision.pull ?? decision.issue ?? number,
445 tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral",
446 };
447 }
448 // Steps of a session as it goes: for the pages showing it, not the feed.
449 case "session.progressed":
450 default:
451 return null;
452 }
453}
454
455// ── Session trees ───────────────────────────────────────────────────────
456
457/** A session with its subagents under it, and theirs under them. */
458export type SessionNode = { session: WorkSession; children: SessionNode[] };
459
460/**
461 * Sessions as trees, by `parentId`: the roots in the order given, each
462 * with its subagents in the order given. One whose parent is not in the
463 * list is a root of its own.
464 */
465export function sessionTree(sessions: WorkSession[]): SessionNode[] {
466 const nodes = new Map<string, SessionNode>();
467 for (const session of sessions) if (!nodes.has(session.id)) nodes.set(session.id, { session, children: [] });
468 const roots: SessionNode[] = [];
469 for (const node of nodes.values()) {
470 const parent = node.session.parentId ? nodes.get(node.session.parentId) : undefined;
471 // A parent that is the session itself or under it (a cycle) does not count.
472 if (parent && !contains(node, parent)) parent.children.push(node);
473 else roots.push(node);
474 }
475 return roots;
476}
477
478/** The sessions of several lists, each once (as the first list to have it says), in the order first given. */
479export function mergeSessions(...lists: (WorkSession[] | null | undefined)[]): WorkSession[] {
480 const seen = new Map<string, WorkSession>();
481 for (const list of lists) for (const session of list ?? []) if (!seen.has(session.id)) seen.set(session.id, session);
482 return [...seen.values()];
483}
484
485function contains(node: SessionNode, target: SessionNode): boolean {
486 return node === target || node.children.some((child) => contains(child, target));
487}
488
489/** How many sessions are under a tree's root, at any depth. */
490export function treeSize(node: SessionNode): number {
491 return node.children.reduce((total, child) => total + 1 + treeSize(child), 0);
492}
493
494/** How a session was tied to an issue or pull request, in a few words. */
495export const VIA_LABELS: Record<LinkVia, string> = {
496 attached: "attached",
497 claim: "claimed",
498 branch: "its branch",
499 commit: "its commit",
500 trailer: "commit trailer",
501 opened: "opened it",
502 ref: "named it",
503};
504
505export function viaLabel(via: LinkVia): string {
506 return VIA_LABELS[via] ?? via;
507}
508
509/** What each kind of thing a session touched is called. */
510export const TOUCH_KINDS: Record<TouchKind, string> = {
511 file: "Files",
512 commit: "Commits",
513 branch: "Branches",
514 pull: "Pull requests",
515 issue: "Issues",
516};
517
518/** What was touched, grouped by kind in TOUCH_KINDS' order, each value once, in the order given. */
519export function touchedByKind(touched: { kind: TouchKind; value: string }[]): { kind: TouchKind; values: string[] }[] {
520 return (Object.keys(TOUCH_KINDS) as TouchKind[])
521 .map((kind) => ({ kind, values: [...new Set(touched.filter((one) => one.kind === kind).map((one) => one.value))] }))
522 .filter((group) => group.values.length > 0);
523}
524
525/** "12s", "4m", "1h 20m": how long a session ran, or has run until `now`. */
526export function duration(session: Pick<WorkSession, "startedAt" | "endedAt" | "lastSeenAt" | "state">, now: number): string {
527 const end = session.endedAt ? Date.parse(session.endedAt) : session.state === "ended" ? Date.parse(session.lastSeenAt) : now;
528 const ms = Math.max(0, end - Date.parse(session.startedAt));
529 return ms < MINUTE ? `${Math.floor(ms / 1000)}s` : span(ms);
530}
531
532/** Splits text at `#N`, for linking each number. */
533export function numberedParts(text: string): (string | number)[] {
534 const parts: (string | number)[] = [];
535 let last = 0;
536 for (const match of text.matchAll(/#(\d+)\b/g)) {
537 if (match.index > last) parts.push(text.slice(last, match.index));
538 parts.push(Number(match[1]));
539 last = match.index + match[0].length;
540 }
541 if (last < text.length) parts.push(text.slice(last));
542 return parts;
543}
544
545/** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */
546export function parseBlockedBy(text: string): number[] | null {
547 const words = text.split(/[\s,]+/).map((word) => word.trim().replace(/^#/, "")).filter(Boolean);
548 if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null;
549 return [...new Set(words.map(Number))];
550}