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
550 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
Merge branch 'worktree-agent-aef0bb4ceda89e542'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
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'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
Merge branch 'worktree-agent-aef0bb4ceda89e542'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
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'153/**
154 * Active claims and sessions grouped by the issue they are on, the issue
Merge branch 'worktree-agent-aef0bb4ceda89e542'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.
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'160 */
Merge branch 'worktree-agent-aef0bb4ceda89e542'161export function liveByIssue(
162 claims: Claim[],
163 sessions: WorkSession[],
164 defaultBranch?: string | null,
165): { issues: LiveIssue[]; groups: LiveGroup[]; loose: WorkSession[] } {
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'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);
Merge branch 'worktree-agent-aef0bb4ceda89e542'173 const byPull = new Map<number, LiveGroup & { kind: "pull" }>();
174 const byBranch = new Map<string, LiveGroup & { kind: "branch" }>();
175 const rest: WorkSession[] = [];
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'176 for (const session of sessions) {
177 if (session.issue != null) at(session.issue).sessions.push(session);
Merge branch 'worktree-agent-aef0bb4ceda89e542'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);
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'187 }
Merge branch 'worktree-agent-aef0bb4ceda89e542'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 };
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'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;
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.307 /** The harness session it is about, by id, for a link to its page. */
308 session?: string;
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'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": {
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.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 }
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'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 }
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.448 // Steps of a session as it goes: for the pages showing it, not the feed.
449 case "session.progressed":
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'450 default:
451 return null;
452 }
453}
454
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.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
Merge branch 'worktree-agent-aa68cf6ecc03dffaf'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}

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