Skip to content

Commit

Merge branch 'worktree-agent-aef0bb4ceda89e542'

syntaqxcommitted Parentsca321a8b838417Browse files
39 files+708−987 files not counted0/39 viewed
+1−1
77 "name": "g1t",
88 "source": "./plugins/claude-code",
99 "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.",
10− "version": "0.2.1",
10+ "version": "0.2.2",
1111 "category": "development"
1212 }
1313 ]
+21−4
102102 CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and issues it and they touched; `links`, the issues and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. Anyone who can read the repository may.",
103103 CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `issue` or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may.",
104104 CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result, note, or skill for a skill it used, by name), `text` (up to 64,000 characters), and for tools `tool`. Give each a `key` and recording it again does nothing, so a retried hook records it once. `touched` says what it touched, up to 200: `{ kind, value }` with kind file (a path from the repository's root), commit, branch, pull (one it opened) or issue (one it named); each is kept once, ties the session to the pull requests and issues it names, and a file another active session touched too (outside its own parents and subagents, so siblings yes) is an overlap.detected event for both, once per pair. Entries of a session attached to a pull request show in that pull request's session too, where reviewers read why the change is what it is. Recording is a heartbeat. Returns how many were `recorded` and how many were `duplicates`.",
105− CoordinationOp::ClaimIssue => "Claim an open issue: say you are working on it. A claim is advisory, never a lock: anyone may claim an issue someone else has, and `others` lists the claims already on it, so you can coordinate or pick something else. It lasts `ttl_minutes` (30 unless you say, up to 240) and is renewed by claiming again or, when you give the `session_id` of one of your sessions, by that session's heartbeats. Returns your `claim`, the `others`, and `blocked_by`: each issue this one waits on, with its claims and pull requests. A new claim is a work.claimed event; one taking over a hand-off says which in `took_over`.",
105+ CoordinationOp::ClaimIssue => "Claim an open issue: say you are working on it. A claim is advisory, never a lock: anyone may claim an issue someone else has, and `others` lists the claims already on it, so you can coordinate or pick something else. It lasts `ttl_minutes` (30 unless you say, up to 240) and is renewed by claiming again or, when you give the `session_id` of one of your sessions, by that session's heartbeats. Returns your `claim`, the `others`, and `blocked_by`: each issue this one waits on, with its claims and pull requests. A new claim is a work.claimed event; one taking over a hand-off says which in `took_over`. For work that has no issue yet, leave out `number` and give `title` (and `body`): the issue is opened first (an issue.opened event), then claimed, the session named by `session_id` is attached to it, and `issue` is the issue opened. The session is checked first, so a claim it would refuse opens nothing.",
106106 CoordinationOp::ReleaseClaim => "Release your claim on an issue, with an optional `reason`: you stopped working on it. A work.released event. Claims also end when the issue closes, and expire when not renewed. Refused with `404` when you hold no active claim on it.",
107107 CoordinationOp::HandOffIssue => "Hand an issue you claimed off: your claim ends as handed_off, with `note` saying where the work stands, for `to` (a username) or, without one, for anyone. A work.handoff_requested event, which whoever follows the issue hears; the next claim on it says it took the hand-off over. Refused with `404` when you hold no active claim on it.",
108108 CoordinationOp::ListClaims => "A repository's claims, newest first, up to 100: active ones unless `state` says released, expired or handed_off, and `issue` for one issue's. Anyone who can read the repository may.",
231231 CoordinationOp::ClaimIssue => (
232232 json!({
233233 "repo": repo,
234− "number": number,
235− "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim." },
234+ "number": { "type": "integer", "description": "The issue's number. Left out, give title (and body) to open one and claim it." },
235+ "title": { "type": "string", "description": "Without number: the title of a new issue to open and claim." },
236+ "body": { "type": "string", "description": "Without number: the new issue's description." },
237+ "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim. With title, it is attached to the new issue." },
236238 "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
237239 "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
238240 }),
239− &["repo", "number"],
241+ &["repo"],
240242 ),
241243 CoordinationOp::ReleaseClaim => (
242244 json!({
433435 Ok(minutes) => minutes,
434436 Err(message) => return invalid(message),
435437 };
438+ let title = text(input, "title");
439+ if issue_number == 0 && title.is_none() {
440+ return invalid("Name the issue by its number, or give a title (and body) to open one.".to_owned());
441+ }
442+ if issue_number != 0 && title.is_some() {
443+ return invalid("Give the issue's number or a title for a new one, not both.".to_owned());
444+ }
436445 let args = ClaimArgs {
437446 actor: actor(),
438447 repo,
439448 number: issue_number,
449+ title,
450+ body: text(input, "body"),
440451 session_id: text(input, "session_id"),
441452 note: text(input, "note"),
442453 ttl_minutes,
554565 let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" }));
555566 assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
556567 assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" }));
568+ // Work with no issue yet: one is opened and claimed.
569+ let (op, input) = resolve("POST", "/repos/acme/web/claims", &[], json!({ "title": "Fix previews" }));
570+ assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
571+ assert_eq!(input, json!({ "title": "Fix previews", "repo": "acme/web" }));
557572 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({}));
558573 assert_eq!(op, coordination(CoordinationOp::ReleaseClaim));
559574 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" }));
573588 assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
574589 assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries)));
575590 assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
591+ let claim_new = resolve(Tool::by_name("issue").unwrap(), &json!({ "action": "claim", "repo": "a/b", "title": "Fix previews" }));
592+ assert_eq!(claim_new, Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
576593 assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork)));
577594 }
578595
+3−0
925925 ("DELETE", "snooze") => "unsnooze_thread".to_owned(),
926926 // An issue's decisions, by its number.
927927 ("GET", "decisions") if route.path.contains("/issues/:number/") => "list_issue_decisions".to_owned(),
928+ // A claim that opens its issue first.
929+ ("POST", "claims") => "claim_new_issue".to_owned(),
928930 _ => op.name().to_owned(),
929931 };
930932 if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
954956 "unsave_thread" => "Unsave a thread",
955957 "unsnooze_thread" => "Bring a snoozed thread back",
956958 "list_issue_decisions" => "List an issue's decisions",
959+ "claim_new_issue" => "Open an issue and claim it",
957960 _ => title(route.op),
958961 };
959962 if id.ends_with("_for_workspace") {
+2−0
613613 // sessions of people's own harnesses.
614614 route("GET", "/repos/:owner/:name/issues/:number/work", Op::Coordination(CoordinationOp::GetWork), &[]),
615615 route("POST", "/repos/:owner/:name/issues/:number/claim", Op::Coordination(CoordinationOp::ClaimIssue), &[]),
616+ // Work with no issue yet: open one from a title and claim it.
617+ route("POST", "/repos/:owner/:name/claims", Op::Coordination(CoordinationOp::ClaimIssue), &[]),
616618 route("POST", "/repos/:owner/:name/issues/:number/release", Op::Coordination(CoordinationOp::ReleaseClaim), &[]),
617619 route("POST", "/repos/:owner/:name/issues/:number/handoff", Op::Coordination(CoordinationOp::HandOffIssue), &[]),
618620 route(
+23−4
9292 6. `pull_request` with `get`, to check `overlaps`, `behind` and the checks.
9393 7. `pull_request` with `ready` and a summary of what changed and why.
9494
95+### Work with no issue yet
96+
97+Most of what an agent is asked starts without an issue, and a question,
98+a look around or a trivial fix never needs one. Record the session
99+anyway (`session` with `start`), with a `title` saying what it is for: the
100+team sees it in **Live now** on the project's overview and **Work** page,
101+under its pull request or branch. When the work becomes a change others
102+should know about (a branch to push, a pull request), look for its issue
103+(`issue` with `list` or `list_claims`) and claim it. If there is none,
104+`issue` with `claim`, a `title` and no `number` opens one and claims it
105+in one step, and attaches the session (`session_id`) to it:
106+
107+```json
108+{ "name": "issue", "arguments": { "action": "claim", "repo": "flagon-io/hello", "title": "Preview builds time out on large repositories", "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2" } }
109+```
110+
111+See [Work with no issue yet](/guides/sessions/#work-with-no-issue-yet).
112+
95113 The call that starts the pull request:
96114
97115 ```json
172190 ```
173191
174192 Its hooks record your prompts, the tools Claude uses and what it called
175−them with, and its pushes, through the [g1t CLI](/guides/cli/). They never
176−send what a tool returned. The plugin also gives Claude the issue's work
177−context when a session starts, g1t's MCP server, and commands to claim and
178−hand off issues. See [Claude Code plugin](/guides/claude-code-plugin/) for
193+them with, and its pushes, through the [g1t CLI](/guides/cli/), and title
194+each session with what its first prompt asked for. They never send what a
195+tool returned. The plugin also gives Claude the issue's work context when
196+a session starts (or, with no issue, when to claim or open one), g1t's MCP
197+server, and commands to claim and hand off issues. See [Claude Code plugin](/guides/claude-code-plugin/) for
179198 everything it records, and how to opt out.
180199
181200 Other agents record a session with the same CLI commands, from their
+48−6
77 machine to the work on g1t. With it installed, working in a g1t repository:
88
99 - each Claude Code session is recorded on g1t as a
10− [session](/guides/why-blame/#sessions), attached to its issue and pull
11− request, so the people and agents also working there can see it;
10+ [session](/guides/why-blame/#sessions), titled with what you asked for
11+ and attached to its issue and pull request, so the people and agents
12+ also working there can see it, issue or not;
1213 - Claude starts each session knowing the issue it is on: its definition of
1314 done, who has claimed it, its pull requests and what blocks it;
1415 - Claude has g1t's [MCP tools](/reference/mcp/), three skills for working
6768 | --- | --- | --- |
6869 | A session starts or resumes | `g1t session start --harness claude_code --harness-session-id ID` | A session for this Claude Code session. Starting the same session again, as a resume does, finds it rather than making another. |
6970 | You send a prompt | `g1t session log --kind prompt TEXT` | Your prompt's first 2,000 characters. |
71+| You send the session's first prompt that is not a slash command | `g1t session heartbeat --json`, then, if the session has no title, `g1t session attach --title INTENT` | Its title: what the prompt is for, as its first line or sentence without code blocks or markdown, up to 80 characters, cut on a word with `…`. A title the session already has (one Claude, you or an earlier prompt set) is never replaced. Not sent with prompts kept back. |
7072 | Claude uses a tool | `g1t session log --kind tool_call TEXT` | The tool's name and one line on what it was called with: the command, file, pattern or address, up to 200 characters, with anything that looks like a token or password replaced by `…`. |
7173 | Claude uses a skill, or you run one (`/g1t:work-on-issue 42`) | `g1t session log --kind skill NAME` | The skill's name, nothing more, once in a turn. A plugin's skill or command you type as `/plugin:name` is recorded even with prompts kept back. |
7274 | Claude writes a file, makes a branch or a commit, or claims an issue | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--issue`) | What it touched: the path from the repository's root, the branch, the commit, the issue. g1t ties the session to the pull requests and issues these lead to, and tells two sessions that touch the same file. |
113115 recorded on it and the last integration failure. If the answer takes longer
114116 than a second, Claude is told to load it itself.
115117
116−With no issue in sight, Claude is told how to load and claim one.
118+With no issue in sight, Claude is told that the session is already
119+recorded on g1t and shows to the team with what it is for, so it can just
120+work; see [Work with no issue yet](#work-with-no-issue-yet).
117121
118122 Claude is also told to put the trailer `G1t-Session: $G1T_SESSION` on the
119123 commits it makes, so g1t ties them, and the pull request they land in, to
123127 Anthropic connection, or `g1t work dispatch N --handoff`) runs as
124128 `G1T_ISSUE=N claude '…'`: the plugin attaches it to issue N as it starts.
125129
130+## Work with no issue yet
131+
132+Most sessions start from a question or a request, not an issue, and most
133+never need one. The session is the record of that work:
134+
135+1. **It is titled with what it is for.** At the session's first prompt
136+ that is not a slash command (`/clear` and `/g1t:status` are not), the
137+ plugin gives the session a title from it, unless it has one. To name
138+ it yourself, or have Claude name it, run
139+ `g1t session attach --title "Fix the preview builds"`; that replaces
140+ the title the prompt gave.
141+2. **It shows to the team.** The project's overview and its **Work** page
142+ list it in **Live now** under its pull request, else its branch, else
143+ under **No branch yet**, with who it is for, Claude Code, its state,
144+ when it was last heard from and its branch.
145+3. **It gets an issue when it becomes a change** others should know about:
146+ a branch to push, a pull request. Claude is told to look for an issue
147+ first (`g1t work list`, `g1t issue list`) and claim it with
148+ `g1t work claim N`, and if there is none, to open one and claim it in
149+ one step:
150+
151+ ```sh
152+ g1t work claim --title "Preview builds time out on large repositories"
153+ ```
154+
155+ The issue is opened, claimed for this session, and the session is
156+ attached to it, so **Live now** lists it under the issue from then on.
157+ Claude is told never to open issues for questions, exploration or
158+ trivial fixes. `/g1t:claim` with a title instead of a number does the
159+ same.
160+
126161 ## Subagents
127162
128163 Each subagent Claude Code starts is recorded as a session of its own,
178213
179214 | Command | What it does |
180215 | --- | --- |
181−| `/g1t:claim N [note]` | Loads issue N's context and claims it for this session, telling you who else holds a claim. |
182−| `/g1t:status` | This session, and the repository's active claims and sessions. |
216+| `/g1t:claim N [note]` | Loads issue N's context and claims it for this session, telling you who else holds a claim. With a title instead of a number (or nothing), looks for an issue that covers the work in hand, and opens and claims one with `g1t work claim --title` if there is none. |
217+| `/g1t:status` | This session (its title, issue, branch or pull request), and the repository's active claims and sessions. |
183218 | `/g1t:handoff N [@login]` | Pushes what is unpushed, writes a hand-off note and hands the issue off, to anyone or to one person. |
184219
185220 A claim says you are working on an issue. It is never a lock: a second
249284 This repository is on g1t. Other agents and people may be working in it at
250285 the same time; the `g1t` CLI is how you see them and how they see you.
251286
252−- When you start, record a session: `g1t session start --harness codex`
287+- When you start, record a session titled with what you were asked to do:
288+ `g1t session start --harness codex --title "Fix the preview builds"`
253289 (or `cursor`, `copilot`, `other`: the harness you are). The CLI remembers
254290 it for this working copy. Run `g1t session heartbeat` every few minutes
255291 while you work, and `g1t session end` when you stop.
292+- With no issue, just work: the session shows to the team. When the work
293+ becomes a change others should know about (a branch to push, a pull
294+ request), look for its issue (`g1t work list`, `g1t issue list`) and
295+ claim it; if there is none, open and claim one with
296+ `g1t work claim --title "TITLE"`. Never open issues for questions,
297+ exploration or trivial fixes.
256298 - Before working on an issue, load it with `g1t work context N`: its
257299 definition of done, who has claimed it, its pull requests, checks and
258300 overlaps, and what blocks it. Then claim it with
+9−1
346346 | `g1t session start` | Starts a session and prints its id. `--harness` (`claude_code`, `codex`, `cursor`, `copilot` or `other`; `claude-code` works too; `claude_managed_agents` is only for the sessions g1t starts itself, with `g1t work dispatch`), `--harness-session-id`, `--issue`, `--pull`, `--branch` (the current one by default) and `--title`. Starting again with the same `--harness-session-id` returns the same session. For a subagent, `--parent` (the session that started it), `--agent-type`, `--task`, `--prompt` and `--model`; a subagent's session is not kept as the working copy's (nor is one with `--no-remember`). |
347347 | `g1t session heartbeat` | Says the session is still working, and renews its claims. A session with no heartbeat for 15 minutes reads as idle, and after 2 hours as ended. |
348348 | `g1t session log TEXT` | Records a note. `--kind` takes `note`, `prompt`, `message`, `tool_call` (or `tool-call`) or `skill` (a skill's name), `--tool` names the tool, and `--key` makes recording the same entry twice do nothing. `-` reads the text from stdin; put `--` before text that starts with a dash. |
349−| `g1t session attach` | Points the session at an `--issue`, a `--pull`, a `--branch` or its latest `--head-commit`. |
349+| `g1t session attach` | Points the session at an `--issue`, a `--pull`, a `--branch` or its latest `--head-commit`, or gives it a `--title`: what it is for, which replaces the title it had. |
350350 | `g1t session touch` | Says what the session touched: `--file PATH`, `--commit SHA`, `--branch NAME`, `--pull N` (one it opened), `--issue N`, each repeatable. Ties the session to the pull requests and issues they lead to; a file another active session touched too is an `overlap.detected` for both. |
351351 | `g1t session end` | Ends it, with an optional `--reason` and `--result` (what it reported; `-` reads it from stdin), and forgets it. Its subagents still going end with it. |
352352 | `g1t session list` | The repository's sessions; `--state active`, `--issue`, `--pull` (any session tied to it), `--path` (those that touched a file, or a folder ending in `/`), `--subagents` to list subagents under the sessions that started them. |
361361 ```sh
362362 g1t work context 12 # everything about #12's work
363363 g1t work claim 12 --note "Taking the parser" --ttl 1h
364+g1t work claim --title "Preview builds time out" --body "Over 1 GB, the build stops at 10 minutes."
364365 g1t work release 12 --reason done
365366 g1t work handoff 12 --to ana --note "Parser done; lexer tests still fail."
366367 g1t work list # active claims and sessions here
382383 current session's, so two sessions of yours (sibling subagents) each hold
383384 their own.
384385
386+For work that has no issue yet, `g1t work claim --title "…"` (with
387+`--body` for its description) opens the issue and claims it in one step,
388+and attaches the current session to it; it prints the new issue's number.
389+Use it once the work is a change others should know about, after looking
390+for an issue that already covers it; questions, exploration and trivial
391+fixes need none. A number and `--title` together are refused.
392+
385393 ### Starting a session on an issue
386394
387395 ```sh
+60−7
1414 - **Claims**: an advisory, expiring "I'm working on #12". Never a lock.
1515 - **Dependencies**: the issues that have to land before this one.
1616
17+Until there is an issue, the session is the unit: work that starts from a
18+question or a quick request is recorded as a session titled with what it
19+is for, and shows to the team on its branch or pull request. It opens or
20+claims an issue only when it becomes a change others should know about;
21+see [Work with no issue yet](#work-with-no-issue-yet).
22+
1723 Nothing here waits on a harness. A harness that crashes, loses its
1824 network or sends the same thing twice does no harm: sessions and claims
1925 that go quiet are read as idle, ended or expired the next time anyone
2834 - **The Work page**, `g1t.sh/<workspace>/<repo>/work`, a tab beside
2935 **Pull requests**, **Merge queue** and **Sessions**:
3036 - **Live now**: the active claims and the active and idle sessions,
31− grouped by the issue they are on, with sessions on no issue yet
32− after them. An issue with more than one claim is marked.
37+ grouped by the issue they are on, the most recently active first. An
38+ issue with more than one claim is marked. Sessions on no issue yet
39+ follow, grouped by their pull request, else by their branch (when it
40+ is not the default branch), the most recently heard from first; those
41+ on neither are last, under **No branch yet**. Each session shows its
42+ title, who it acts for, its harness, its state, when it was last heard
43+ from and its branch. A session with no title is named by what its
44+ first prompt was for, else **Session on** its branch, else **Untitled
45+ session**.
3346 - **Merge queue**: what is waiting to land and what is being tested,
3447 as on the [Merge queue](/guides/merge-queue/#the-merge-queue-page) page.
3548 - **What happened**: the latest 30 coordination events, from claims to
103116 `cursor`, `copilot` or `other`) and the harness's own id for the session as
104117 `harness_session_id`. Starting again with the same id returns the
105118 session already started, so a hook can call it every time without
106− checking first. Give `issue`, `pull` and `branch` when you know them.
119+ checking first. Give `issue`, `pull` and `branch` when you know them,
120+ and a `title`: what the session is for, in a few words (up to 200
121+ characters).
107122 2. **Heartbeat** with `session` `update`, or
108123 [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/)
109124 with an empty body. The same call attaches the session to an issue or
110− pull request, or records the `head_commit` it pushed.
125+ pull request, records the `head_commit` it pushed, or changes its
126+ `title`.
111127 3. **Record** what happens with `session` `record`, or
112128 [`POST /repos/{owner}/{name}/sessions/{id}/entries`](/reference/api/sessions/record-session-entries/):
113129 prompts, reasoning, tool calls, notes and the skills it used (`skill`,
146162 [`GET /repos/{owner}/{name}/sessions`](/reference/api/sessions/list-sessions/)
147163 by `state`, `issue` or `pull` (any session tied to it), or `path` (those
148164 that touched a file, or any under a folder ending in `/`); subagents are
149−listed too with `subagents=true`. Read one with its entries, subagents,
150−links, skills and what it touched with `session` `get`.
165+listed too with `subagents=true`. A session in the list with no `title`
166+carries `first_prompt`: the first 300 characters of the first prompt it
167+recorded. Read one with its entries, subagents, links, skills and what it
168+touched with `session` `get`.
151169
170+## Work with no issue yet
171+
172+Not every session starts from an issue, and most should not end with one:
173+a question, a look around or a quick fix needs no issue. The session is
174+the record of that work, and the team sees it in **Live now** while it
175+goes:
176+
177+1. **It says what it is for.** Give it a `title` when you start it, or
178+ later with `session` `update`. The
179+ [Claude Code plugin](/guides/claude-code-plugin/#work-with-no-issue-yet)
180+ titles each session from its first prompt that is not a slash command,
181+ and never replaces a title the session already has.
182+2. **It shows where it works.** Attach its `branch` and, once there is
183+ one, its `pull`: **Live now** groups sessions on no issue by pull
184+ request, then by branch.
185+3. **It opens or claims an issue when it becomes a change** others should
186+ know about: a branch to push, a pull request. Look for one first
187+ (`issue` `list`, `issue` `list_claims`); if one covers the work,
188+ claim it. Otherwise open one and claim it in one step with `issue`
189+ `claim` and a `title` instead of a `number` (see [Claims](#claims)),
190+ which attaches the session to it.
191+
152192 ## Subagents
153193
154194 A harness that hands part of its work to a subagent (Claude Code's
277317 The answer has your `claim`, the `others` already on the issue, and
278318 `blocked_by`: each issue this one waits on, with its claims and pull
279319 requests.
320+- **Open an issue and claim it** in one step, for work that has no issue
321+ yet: `issue` `claim` with `title` (and `body`) and no `number`, or
322+ [`POST /repos/{owner}/{name}/claims`](/reference/api/claims-and-work/claim-new-issue/).
323+ The issue is opened (an `issue.opened` event), then claimed, and the
324+ session named by `session_id` is attached to it. The answer also has
325+ `issue`, the issue opened. The session is checked first: a claim that
326+ would be refused for it opens nothing.
280327 - **Renew** it by claiming again, or give the `session_id` of your session
281328 when you claim, and that session's heartbeats renew it.
282329 - **Release** it with `issue` `release`, or
293340 -H "Authorization: Bearer $G1T_TOKEN" \
294341 -H "Content-Type: application/json" \
295342 -d '{"session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", "note": "Taking the argument parsing first."}'
343+
344+curl -X POST https://api.g1t.sh/repos/acme/web/claims \
345+ -H "Authorization: Bearer $G1T_TOKEN" \
346+ -H "Content-Type: application/json" \
347+ -d '{"session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", "title": "Preview builds time out on large repositories"}'
296348 ```
297349
298350 | | |
299351 | --- | --- |
300352 | How long a claim lasts | `ttl_minutes`: 30 unless you say, at most 240. |
353+| Opening its issue | `title` (and `body`) instead of `number`. Giving both is refused with `422`. |
301354 | When it ends | Released, handed off, its issue closed, or not renewed in time (`expired`). |
302355 | States | `active`, `released`, `expired`, `handed_off`. |
303356
372425 | To | You need |
373426 | --- | --- |
374427 | Read sessions, claims and an issue's context | To be able to read the repository. Public repositories need no token. |
375−| Start a session, claim an issue | A confirmed email address and a role that may open issues; the repository must not be archived. |
428+| Start a session, claim an issue, open one by claiming it | A confirmed email address and a role that may open issues; the repository must not be archived. |
376429 | Update, record in or end a session | To be the person whose token started it. |
377430 | Release a claim or hand it off | To be the person who made it. |
378431 | Set `blocked_by` | To be the issue's author, or the Triage role. |
+16−5
944944
945945 ### g1t session attach
946946
947−Attach the current session to an issue, a pull request or a branch.
947+Attach the current session to an issue, a pull request or a branch, or give it a title.
948948
949949 ```text
950−g1t session attach [OPTIONS] <--issue <ISSUE>|--pull <PULL>|--branch <BRANCH>|--head-commit <SHA>>
950+g1t session attach [OPTIONS] <--issue <ISSUE>|--pull <PULL>|--branch <BRANCH>|--head-commit <SHA>|--title <TITLE>>
951951 ```
952952
953953 | Argument or flag | |
956956 | `-p, --pull <PULL>` | |
957957 | `-b, --branch <BRANCH>` | |
958958 | `--head-commit <SHA>` | The commit it pushed last. |
959+| `-t, --title <TITLE>` | What it is for, in a few words; replaces its title. |
959960
960961 Calls [`update_session`](/reference/api/sessions/update-session/).
961962
10531054 Claim an issue: say you are working on it. Others are told, never stopped.
10541055
10551056 ```text
1056−g1t work claim [OPTIONS] <NUMBER>
1057+g1t work claim [OPTIONS] <NUMBER|--title <TITLE>>
10571058 ```
10581059
10591060 | Argument or flag | |
10601061 | --- | --- |
1061−| `<NUMBER>` | The issue's number. |
1062+| `[NUMBER]` | The issue's number. |
1063+| `--title <TITLE>` | Open a new issue with this title and claim it; the current session is attached to it. |
1064+| `--body <BODY>` | The new issue's description. |
10621065 | `-n, --note <NOTE>` | What you are doing about it. |
10631066 | `--ttl <DURATION>` | How long the claim lasts unless renewed: 30m, 2h (at most 4h). |
10641067
1065−Calls [`claim_issue`](/reference/api/claims-and-work/claim-issue/).
1068+```text
1069+With --title instead of a number, a new issue is opened and claimed in one step, and the
1070+current session is attached to it: for work that became a change others should know about.
1071+Examples:
1072+ g1t work claim 12 --note "Rewriting the retry loop"
1073+ g1t work claim --title "Preview builds time out on large repositories"
1074+```
1075+
1076+Calls [`claim_issue`](/reference/api/claims-and-work/claim-issue/), [`claim_new_issue`](/reference/api/claims-and-work/claim-new-issue/).
10661077
10671078 ### g1t work release
10681079
+1−1
346346 | [`delete_comment`](/reference/api/issues/delete-comment/) | Delete a comment, by its id. Your own, or anyone's with the Maintain role or higher. A review that approved or requested changes cannot be deleted. | `repo`, `comment_id` | `issues:write` |
347347 | [`import`](/reference/api/integrations/import-issue/) | Open an issue from a Jira, Linear or Sentry item, linked to it. | `repo`, `reference` | `issues:write` |
348348 | [`work`](/reference/api/claims-and-work/get-work/) | Everything to pick an issue up: its definition of done and required checks, active claims, sessions, pull requests (checks, mergeability, overlaps, queue place), what it is blocked by and blocks, decisions, and its recent coordination events. | `repo`, `number` | `issues:read` |
349−| [`claim`](/reference/api/claims-and-work/claim-issue/) | Say you are working on an open issue: advisory and expiring (`ttl_minutes`, 30 unless you say, at most 240), never a lock. Returns `others` already on it and what it is `blocked_by`. Claiming again, or a heartbeat of its `session_id`, renews it. | `repo`, `number` | `issues:write` |
349+| [`claim`](/reference/api/claims-and-work/claim-issue/) | Say you are working on an open issue: advisory and expiring (`ttl_minutes`, 30 unless you say, at most 240), never a lock. Returns `others` already on it and what it is `blocked_by`. Claiming again, or a heartbeat of its `session_id`, renews it. With `title` (and `body`) instead of `number`, it [opens the issue first](/reference/api/claims-and-work/claim-new-issue/), claims it, attaches the `session_id` session to it, and returns it as `issue`. | `repo`, and `number` or `title` | `issues:write` |
350350 | [`release`](/reference/api/claims-and-work/release-claim/) | Give up your claim, with an optional `reason`. | `repo`, `number` | `issues:write` |
351351 | [`handoff`](/reference/api/claims-and-work/hand-off-issue/) | End your claim as handed off, `to` someone or anyone, with a `note` on where the work stands. | `repo`, `number`, `note` | `issues:write` |
352352 | [`list_claims`](/reference/api/claims-and-work/list-claims/) | Claims on a repository's issues, newest first: active unless `state` says otherwise, `issue` for one. | `repo` | `issues:read` |
+0−0

Binary or large file; its contents are not shown.

+80−17
3737 type Decision,
3838 type DecisionConfidence,
3939 type Harness,
40+ type Pull,
4041 type SessionState,
4142 type State,
4243 type WorkContext,
5455 evidenceOf,
5556 expiresIn,
5657 harnessName,
58+ type LiveGroup,
5759 type LiveIssue,
5860 liveByIssue,
5961 mergeSessions,
6062 numberedParts,
63+ sessionTitle,
6164 sessionTree,
6265 } from "../lib/coordination";
6366 import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui";
7477 import { RadioGroup, RadioOption } from "./ui/radio-group";
7578 import { Textarea } from "./ui/textarea";
7679 import { PersonLink } from "./work";
77−import { IssueIcon } from "./work-icons";
80+import { IssueIcon, PullIcon } from "./work-icons";
7881
7982 /** Text with each `#N` linked to that issue or pull request (the issue's address leads to a pull request too). */
8083 export function NumberedText({ text, base }: { text: string; base: string }) {
189192 <div className="min-w-0 grow">
190193 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
191194 <Link to={`${base}/work/sessions/${session.id}`} prefetch="intent" className="font-medium hover:text-accent">
192− {session.title ?? `${session.actor.username}'s session`}
195+ {sessionTitle(session)}
193196 </Link>
194197 <HarnessBadge harness={session.harness} />
195198 <SessionStateBadge state={session.state} />
783786 );
784787 }
785788
789+/** A pull request a session is on, as Live now names it. */
790+export type LivePull = { title: string; status: Pull["status"] };
791+
792+/** What a group of sessions on no issue is on, as its card's heading: the pull request, the branch, or neither. */
793+function LiveGroupHeading({ group, pulls, base }: { group: LiveGroup; pulls: Record<number, LivePull>; base: string }) {
794+ if (group.kind === "pull") {
795+ const pull = pulls[group.pull];
796+ return (
797+ <div className="flex items-start gap-2.5 px-4 py-3">
798+ <span className="mt-1">
799+ <PullIcon status={pull?.status ?? "open"} />
800+ </span>
801+ <Link to={`${base}/pull/${group.pull}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent">
802+ {pull?.title ?? `Pull request #${group.pull}`} <span className="font-normal text-faint">#{group.pull}</span>
803+ </Link>
804+ <span className="mt-0.5 shrink-0 text-xs text-faint">No issue yet</span>
805+ </div>
806+ );
807+ }
808+ if (group.kind === "branch") {
809+ return (
810+ <div className="flex items-center gap-2.5 px-4 py-3">
811+ <GitBranch size={16} className="shrink-0 text-muted" />
812+ <Link to={`${base}/tree/${encodeURIComponent(group.branch)}`} prefetch="intent" className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
813+ {group.branch}
814+ </Link>
815+ <span className="ml-auto shrink-0 text-xs text-faint">No issue yet</span>
816+ </div>
817+ );
818+ }
819+ return (
820+ <div className="flex items-center gap-2.5 px-4 py-3">
821+ <Activity size={16} className="shrink-0 text-faint" />
822+ <Hint label="Sessions on no issue, pull request or branch of their own yet, each titled with what it is for. One opens or claims an issue when its work becomes a change.">
823+ <span className="text-sm text-muted">No branch yet</span>
824+ </Hint>
825+ </div>
826+ );
827+}
828+
829+/** Sessions on no issue yet that share a pull request or a branch, or share neither: a heading, then each session. */
830+export function LiveGroupCard({ group, pulls, base }: { group: LiveGroup; pulls: Record<number, LivePull>; base: string }) {
831+ return (
832+ <Card asChild divided>
833+ <li>
834+ <LiveGroupHeading group={group} pulls={pulls} base={base} />
835+ {group.sessions.map((session) => (
836+ <div key={session.id} className="px-4 py-3">
837+ {/* Its pull request and branch are the heading's, unless it has neither of its own. */}
838+ <SessionLine session={session} base={base} showWhere={group.kind === "none"} />
839+ </div>
840+ ))}
841+ </li>
842+ </Card>
843+ );
844+}
845+
786846 /**
787847 * Who is working right now: each issue with its claims and sessions, the
788− * most recently active first, then sessions on no issue yet. `limit` caps
789− * the issues shown, for a page that links to the rest.
848+ * most recently active first, then sessions on no issue yet, by pull
849+ * request, then by branch, then those on neither. `limit` caps the issues
850+ * shown, for a page that links to the rest.
790851 */
791852 export function LiveNowList({
792853 claims,
793854 sessions,
794855 titles,
856+ pulls = {},
857+ defaultBranch,
795858 base,
796859 now,
797860 limit,
799862 claims: Claim[];
800863 sessions: WorkSession[];
801864 titles: Record<number, string>;
865+ /** The open pull requests' titles and states, for the sessions on one with no issue. */
866+ pulls?: Record<number, LivePull>;
867+ /** Sessions on it have no branch of their own. */
868+ defaultBranch?: string | null;
802869 base: string;
803870 now: number;
804871 limit?: number;
805872 }) {
806− const live = liveByIssue(claims, sessions);
873+ const live = liveByIssue(claims, sessions, defaultBranch);
807874 const issues = limit == null ? live.issues : live.issues.slice(0, limit);
808875 return (
809876 <ol className="space-y-3">
810877 {issues.map((group) => (
811878 <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} />
812879 ))}
813− {live.loose.length > 0 && (
814− <Card asChild divided>
815− <li>
816− <p className="px-4 py-3 text-sm text-muted">Not on an issue yet</p>
817− {live.loose.map((session) => (
818− <div key={session.id} className="px-4 py-3">
819− <SessionLine session={session} base={base} />
820− </div>
821− ))}
822− </li>
823− </Card>
824− )}
880+ {live.groups.map((group) => (
881+ <LiveGroupCard
882+ key={group.kind === "pull" ? `pull:${group.pull}` : group.kind === "branch" ? `branch:${group.branch}` : "none"}
883+ group={group}
884+ pulls={pulls}
885+ base={base}
886+ />
887+ ))}
825888 </ol>
826889 );
827890 }
+51−0
99 expiresIn,
1010 feedLine,
1111 harnessName,
12+ intentTitle,
1213 liveByIssue,
14+ sessionTitle,
1315 duration,
1416 mergeSessions,
1517 numberedParts,
117119 assert.deepEqual(loose.map((one) => one.id), ["ses_b"]);
118120 });
119121
122+test("sessions on no issue are grouped by pull request, then by a branch of their own, then the rest together", () => {
123+ const seen = (minute: number) => `2026-10-12T10:${String(minute).padStart(2, "0")}:00Z`;
124+ const { issues, groups, loose } = liveByIssue(
125+ [],
126+ [
127+ session("ses_main", { branch: "main", lastSeenAt: seen(50) }),
128+ session("ses_fix1", { branch: "fix-previews", lastSeenAt: seen(10) }),
129+ session("ses_pr", { pull: 12, branch: "tidy-readme", lastSeenAt: seen(20) }),
130+ session("ses_fix2", { branch: "fix-previews", lastSeenAt: seen(40) }),
131+ session("ses_none", { lastSeenAt: seen(30) }),
132+ session("ses_issue", { issue: 3, branch: "3-x", lastSeenAt: seen(5) }),
133+ ],
134+ "main",
135+ );
136+ assert.deepEqual(issues.map((group) => group.issue), [3]);
137+ assert.deepEqual(
138+ groups.map((group) => [group.kind === "pull" ? `#${group.pull}` : group.kind === "branch" ? group.branch : "none", group.sessions.map((one) => one.id)]),
139+ [
140+ // The most recently heard from first; the rest, on no branch of their own (the default branch is none), last.
141+ ["fix-previews", ["ses_fix2", "ses_fix1"]],
142+ ["#12", ["ses_pr"]],
143+ ["none", ["ses_main", "ses_none"]],
144+ ],
145+ );
146+ assert.deepEqual(loose.map((one) => one.id), ["ses_fix2", "ses_fix1", "ses_pr", "ses_main", "ses_none"]);
147+ // Without the default branch known, its sessions are a branch's.
148+ assert.equal(liveByIssue([], [session("ses_main", { branch: "main" })]).groups[0].kind, "branch");
149+});
150+
151+test("a session is never title-less: its title, its first prompt's intent, its branch, or untitled", () => {
152+ assert.equal(sessionTitle({ title: "Rewrite the auth flow", branch: "auth", firstPrompt: "fix it" }), "Rewrite the auth flow");
153+ assert.equal(sessionTitle({ title: null, branch: "auth", firstPrompt: "fix the login redirect. It loops." }), "Fix the login redirect");
154+ assert.equal(sessionTitle({ title: " ", branch: "auth", firstPrompt: "/clear" }), "Session on auth");
155+ assert.equal(sessionTitle({ title: null, branch: null }), "Untitled session");
156+ assert.equal(sessionTitle({ title: null, branch: null }, "look at the CI"), "Look at the CI", "a first prompt given stands in");
157+});
158+
159+test("a prompt's intent is its first line or sentence, plain, clipped on a word", () => {
160+ assert.equal(intentTitle("Make preview builds faster. They time out on big repositories."), "Make preview builds faster");
161+ assert.equal(intentTitle("Why does `src/app.ts` fail? It worked yesterday."), "Why does src/app.ts fail?");
162+ assert.equal(intentTitle("```js\nconst a = 1;\n```\n## Clean **this** up\nmore"), "Clean this up");
163+ assert.equal(intentTitle("\n\n- look at [the docs](https://docs.g1t.sh) for g1t.mjs"), "Look at the docs for g1t.mjs");
164+ const long = intentTitle("Tidy up the README and then look at the CI pipeline, which keeps failing on the Windows runners every night")!;
165+ assert.ok(long.length <= 80 && long.endsWith("…") && !long.endsWith(" …"), long);
166+ for (const prompt of ["/clear", "/g1t:status 4", "<command-name>/clear</command-name>", "```\ncode only\n```", "?", "", null]) {
167+ assert.equal(intentTitle(prompt), null, String(prompt));
168+ }
169+});
170+
120171 test("evidence is read as far as it goes", () => {
121172 const failed = decision("integration_failure", "2026-10-12T10:00:00Z", {
122173 evidence: { commit: "abc1234", check: { name: "CI / merge_group", conclusion: "failure", url: "https://g1t.sh/acme/web/actions/runs/run_1" }, combination: [4, 5], suspects: [4], bisected: false },
+94−11
8383 ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." },
8484 };
8585
86+// ── Session titles ──────────────────────────────────────────────────────
87+
88+/** The most of a prompt's intent that titles a session. */
89+export 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+ */
98+export 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+ */
129+export 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+
86140 // ── Live now ────────────────────────────────────────────────────────────
87141
88142 /** One issue being worked on: its claims and the sessions on it. */
89143 export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] };
90144
145+/** Sessions on no issue yet, together: on one pull request, on one branch of their own, or on neither. */
146+export type LiveGroup =
147+ | { kind: "pull"; pull: number; sessions: WorkSession[] }
148+ | { kind: "branch"; branch: string; sessions: WorkSession[] }
149+ | { kind: "none"; sessions: WorkSession[] };
150+
151+const latestSeen = (sessions: WorkSession[]) => Math.max(0, ...sessions.map((session) => Date.parse(session.lastSeenAt)));
152+
91153 /**
92154 * 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.
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.
94160 */
95−export function liveByIssue(claims: Claim[], sessions: WorkSession[]): { issues: LiveIssue[]; loose: WorkSession[] } {
161+export function liveByIssue(
162+ claims: Claim[],
163+ sessions: WorkSession[],
164+ defaultBranch?: string | null,
165+): { issues: LiveIssue[]; groups: LiveGroup[]; loose: WorkSession[] } {
96166 const byIssue = new Map<number, LiveIssue>();
97167 const at = (issue: number) => {
98168 let group = byIssue.get(issue);
100170 return group;
101171 };
102172 for (const claim of claims) at(claim.issue).claims.push(claim);
103− const loose: WorkSession[] = [];
173+ const byPull = new Map<number, LiveGroup & { kind: "pull" }>();
174+ const byBranch = new Map<string, LiveGroup & { kind: "branch" }>();
175+ const rest: WorkSession[] = [];
104176 for (const session of sessions) {
105177 if (session.issue != null) at(session.issue).sessions.push(session);
106− else loose.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);
107187 }
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 };
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+ };
115198 }
116199
117200 // ── Decisions ───────────────────────────────────────────────────────────
+19−3
3333 import { githubApp } from "../../lib/github.server";
3434 import { ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
3535 import { Card } from "../../components/ui/card";
36−import { LiveNowList, StartHere } from "../../components/coordination";
36+import { type LivePull, LiveNowList, StartHere } from "../../components/coordination";
3737 import { liveByIssue } from "../../lib/coordination";
3838 import { type Need, ageBuckets, firstPassRate, passRate, rankNeeds, runHealth } from "../../lib/mission";
3939 import { count, duration, percent } from "../../lib/observability";
250250 // --- Live now ------------------------------------------------------------------
251251 const titles: Record<number, string> = {};
252252 for (const issue of openIssues ?? []) titles[issue.number] = issue.title;
253+ // For sessions on a pull request or a branch of their own, with no issue yet.
254+ const livePulls: Record<number, LivePull> = {};
255+ for (const pull of openPulls) livePulls[pull.number] = { title: pull.title, status: pull.status };
256+ const repoValue = await repoP;
257+ const defaultBranch = repoValue?.ok ? repoValue.value.defaultBranch : null;
253258
254259 // --- Where it runs ------------------------------------------------------------
255260 const environmentList = ok(environments);
308313 sessions: [...(ok(active) ?? []), ...(ok(idle) ?? [])],
309314 liveLoaded: claims?.ok === true || active?.ok === true,
310315 titles,
316+ livePulls,
317+ defaultBranch,
311318 health: {
312319 // A pull request's checks when any finished lately; otherwise the
313320 // project's workflow runs, for one that ships straight to its branch.
784791 actionData: Route.ComponentProps["actionData"];
785792 params: Route.ComponentProps["params"];
786793 }) {
787− const { member, project, settings, production, needs, library, claims, sessions, titles, previews } = loaderData;
794+ const { member, project, settings, production, needs, library, claims, sessions, titles, livePulls, defaultBranch, previews } = loaderData;
788795 const base = `/${params.owner}/${params.repo}`;
789796 const name = project?.name ?? params.repo;
790797 const plan = project ? projectPlan(project) : "production";
899906 <Unavailable what="Who is working" />
900907 ) : working ? (
901908 <>
902− <LiveNowList claims={claims} sessions={sessions} titles={titles} base={base} now={Date.now()} limit={LIVE_SHOWN} />
909+ <LiveNowList
910+ claims={claims}
911+ sessions={sessions}
912+ titles={titles}
913+ pulls={livePulls}
914+ defaultBranch={defaultBranch}
915+ base={base}
916+ now={Date.now()}
917+ limit={LIVE_SHOWN}
918+ />
903919 {moreIssues > 0 && (
904920 <Link to={`${base}/work`} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg">
905921 {plural(moreIssues, "more issue")} being worked on
+12−8
1717 import { PersonLink } from "../../components/work";
1818 import { IssueIcon, PullIcon } from "../../components/work-icons";
1919 import { requireRepo } from "../../lib/access.server";
20−import { type SessionNode, TOUCH_KINDS, duration, harnessName, sessionTree, touchedByKind, viaLabel } from "../../lib/coordination";
20+import { type SessionNode, TOUCH_KINDS, duration, harnessName, sessionTitle, sessionTree, touchedByKind, viaLabel } from "../../lib/coordination";
2121 import { page } from "../../lib/meta";
2222 import { notFound } from "../../lib/not-found.server";
2323 import { repoPages } from "../../lib/repo-live";
2727
2828 const MANAGED = "claude_managed_agents";
2929
30−/** What a session is called: its title, a subagent's kind and task, or whose it is. */
31−function sessionName(session: WorkSession): string {
32− if (session.title) return session.title;
33− if (session.parentId) return [session.agentType ?? "Subagent", session.task].filter(Boolean).join(": ");
34− return `${session.actor.username}'s ${harnessName(session.harness) ?? ""} session`.replace(" ", " ");
30+/**
31+ * What a session is called: its title, a subagent's kind and task, else
32+ * what its first prompt was for, its branch, or "Untitled session".
33+ */
34+function sessionName(session: WorkSession, firstPrompt?: string | null): string {
35+ if (!session.title?.trim() && session.parentId) return [session.agentType ?? "Subagent", session.task].filter(Boolean).join(": ");
36+ return sessionTitle(session, firstPrompt ?? session.firstPrompt);
3537 }
3638
3739 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
3840 const session = loaderData?.session;
39− const name = session ? `${sessionName(session)} · ` : "";
41+ const firstPrompt = loaderData?.entries.find((entry) => entry.kind === "prompt")?.text;
42+ const name = session ? `${sessionName(session, firstPrompt)} · ` : "";
4043 return page(args, { title: `${name}Session · ${params.owner}/${params.repo} · g1t` });
4144 }
4245
257260 useRepoLive({ match: repoPages.session() });
258261
259262 const tools = entries.filter((entry) => entry.kind === "tool_call").length;
263+ const firstPrompt = entries.find((entry) => entry.kind === "prompt")?.text;
260264 const isSubagent = Boolean(session.parentId);
261265 const parent = isSubagent && root?.id === session.parentId ? root : null;
262266 // Its own subagents: those whose parent is in this tree (children lists every descendant).
298302
299303 <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
300304 <Actor username={session.actor.username} harness={session.harness} size={24} />
301− <h2 className="min-w-0 grow text-lg font-semibold tracking-tight wrap-break-word">{sessionName(session)}</h2>
305+ <h2 className="min-w-0 grow text-lg font-semibold tracking-tight wrap-break-word">{sessionName(session, firstPrompt)}</h2>
302306 {isSubagent && session.agentType && (
303307 <Badge tone="accent">
304308 <Bot size={11} />
+9−4
55 import type { EventType, Pull } from "@g1t/contracts";
66
77 import type { Route } from "./+types/work";
8−import { CoordinationFeed, LiveNowList, StartHere } from "../../components/coordination";
8+import { CoordinationFeed, type LivePull, LiveNowList, StartHere } from "../../components/coordination";
99 import { QueueTrain } from "../../components/merge-queue";
1010 import { EmptyState, TimeAgo } from "../../components/ui";
1111 import { Avatar } from "../../components/ui/avatar";
3232 const { viewer, repo } = await requireRepo(context, params, "read");
3333 const path = { namespace: params.owner, name: params.repo };
3434 // One round: who is on what, the queue, what happened, what landed.
35− const [claims, active, idle, queue, recent, open, closed] = await Promise.all([
35+ const [claims, active, idle, queue, recent, open, closed, openPulls] = await Promise.all([
3636 work.listClaims(path, viewer).catch(() => null),
3737 work.listWorkSessions(path, viewer, { state: "active" }).catch(() => null),
3838 work.listWorkSessions(path, viewer, { state: "idle" }).catch(() => null),
4747 // The titles of the issues being worked on.
4848 work.listIssues(path, viewer, { state: "open" }).catch(() => null),
4949 work.listPulls(path, viewer, "closed").catch(() => null),
50+ // The titles of the pull requests sessions with no issue are on.
51+ work.listPulls(path, viewer, "open").catch(() => null),
5052 ]);
5153 const titles: Record<number, string> = {};
5254 for (const issue of open?.ok ? open.value : []) titles[issue.number] = issue.title;
55+ const pulls: Record<number, LivePull> = {};
56+ for (const pull of openPulls?.ok ? openPulls.value : []) pulls[pull.number] = { title: pull.title, status: pull.status };
5357 const landed = (closed?.ok ? closed.value : [])
5458 .filter((pull) => pull.status === "merged" && pull.mergedAt)
5559 .sort((a, b) => Date.parse(b.mergedAt!) - Date.parse(a.mergedAt!))
6064 queue: queue?.ok ? queue.value : null,
6165 feed: recent.flatMap((event) => feedLine(event) ?? []),
6266 titles,
67+ pulls,
6368 landed,
6469 defaultBranch: repo.defaultBranch,
6570 };
113118 }
114119
115120 export default function WorkPage({ loaderData, params }: Route.ComponentProps) {
116− const { claims, sessions, queue, feed, titles, landed, defaultBranch } = loaderData;
121+ const { claims, sessions, queue, feed, titles, pulls, landed, defaultBranch } = loaderData;
117122 const base = `/${params.owner}/${params.repo}`;
118123 const now = Date.now();
119124 const working = claims.length > 0 || sessions.length > 0;
145150 {!working ? (
146151 <StartHere title="Nobody is working on this repository right now" />
147152 ) : (
148− <LiveNowList claims={claims} sessions={sessions} titles={titles} base={base} now={now} />
153+ <LiveNowList claims={claims} sessions={sessions} titles={titles} pulls={pulls} defaultBranch={defaultBranch} base={base} now={now} />
149154 )}
150155 </Section>
151156
+25−2
195195 /// How many subagents it started.
196196 #[serde(default)]
197197 pub subagents: u32,
198+ /// For a session with no title, in a list: the start of the first
199+ /// prompt it recorded (up to [`MAX_FIRST_PROMPT_CHARS`]), for saying
200+ /// what it is for until it has a title.
201+ #[serde(default, skip_serializing_if = "Option::is_none")]
202+ pub first_prompt: Option<String>,
198203 }
199204
205+/// The most characters of an untitled session's first prompt a list gives.
206+pub const MAX_FIRST_PROMPT_CHARS: usize = 300;
207+
200208 /// The most characters a subagent's task keeps.
201209 pub const MAX_TASK_CHARS: usize = 200;
202210 /// The most characters of a subagent's prompt kept.
803811 }
804812
805813 /// `claim_issue`. Returns `Outcome<Claimed>`. Claiming an issue you hold
806−/// renews your claim (and changes its note or session when given).
807−#[derive(Debug, Serialize, Deserialize)]
814+/// renews your claim (and changes its note or session when given). With
815+/// no number and a `title`, it opens the issue first and attaches the
816+/// session to it; `Claimed::issue` is the issue it opened.
817+#[derive(Clone, Debug, Serialize, Deserialize)]
808818 pub struct ClaimArgs {
809819 pub actor: User,
810820 pub repo: RepoPath,
821+ /// The issue to claim; 0 with `title` to open one and claim it.
822+ #[serde(default)]
811823 pub number: u32,
824+ /// Without a number: the title of a new issue to open and claim. The
825+ /// session named by `session_id` is attached to it.
826+ #[serde(default)]
827+ pub title: Option<String>,
828+ /// Without a number: the new issue's description.
829+ #[serde(default)]
830+ pub body: Option<String>,
812831 #[serde(default)]
813832 pub session_id: Option<String>,
814833 #[serde(default)]
825844 pub others: Vec<Claim>,
826845 /// What the issue is blocked by, and where each stands.
827846 pub blocked_by: Vec<Blocker>,
847+ /// The issue the claim opened, when it was asked to open one (a
848+ /// `title` instead of a number).
849+ #[serde(default, skip_serializing_if = "Option::is_none")]
850+ pub issue: Option<Issue>,
828851 }
829852
830853 /// `release_claim`: gives up your active claim on an issue. Returns
+50−3
954954 Heartbeat,
955955 /// Record a note, prompt or message in the current session.
956956 Log(SessionLogArgs),
957− /// Attach the current session to an issue, a pull request or a branch.
957+ /// Attach the current session to an issue, a pull request or a branch, or give it a title.
958958 Attach(SessionAttachArgs),
959959 /// End the current session.
960960 End(SessionEndArgs),
10661066 }
10671067
10681068 #[derive(Debug, Args)]
1069−#[command(group(clap::ArgGroup::new("what").required(true).multiple(true).args(["issue", "pull", "branch", "head_commit"])))]
1069+#[command(group(clap::ArgGroup::new("what").required(true).multiple(true).args(["issue", "pull", "branch", "head_commit", "title"])))]
10701070 pub struct SessionAttachArgs {
10711071 #[arg(short, long)]
10721072 pub issue: Option<u64>,
10771077 /// The commit it pushed last.
10781078 #[arg(long, value_name = "SHA")]
10791079 pub head_commit: Option<String>,
1080+ /// What it is for, in a few words; replaces its title.
1081+ #[arg(short, long)]
1082+ pub title: Option<String>,
10801083 }
10811084
10821085 #[derive(Debug, Args)]
11281131 /// Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions.
11291132 Context(WorkNumber),
11301133 /// Claim an issue: say you are working on it. Others are told, never stopped.
1134+ #[command(after_help = "\nWith --title instead of a number, a new issue is opened and claimed in one step, and the
1135+current session is attached to it: for work that became a change others should know about.
1136+Examples:
1137+ g1t work claim 12 --note \"Rewriting the retry loop\"
1138+ g1t work claim --title \"Preview builds time out on large repositories\"")]
11311139 Claim(WorkClaimArgs),
11321140 /// Release your claim on an issue.
11331141 Release(WorkReleaseArgs),
12061214 }
12071215
12081216 #[derive(Debug, Args)]
1217+#[command(group(clap::ArgGroup::new("which").required(true).args(["number", "title"])))]
12091218 pub struct WorkClaimArgs {
12101219 /// The issue's number.
1211− pub number: u64,
1220+ pub number: Option<u64>,
1221+ /// Open a new issue with this title and claim it; the current session is attached to it.
1222+ #[arg(long, conflicts_with = "number")]
1223+ pub title: Option<String>,
1224+ /// The new issue's description.
1225+ #[arg(long, requires = "title")]
1226+ pub body: Option<String>,
12121227 /// What you are doing about it.
12131228 #[arg(short, long)]
12141229 pub note: Option<String>,
15501565 assert_eq!(args.kind.name(), "tool_call");
15511566 }
15521567 }
1568+
1569+ /// `work claim` takes an issue's number, or a title for a new one, never both.
1570+ #[test]
1571+ fn a_claim_names_an_issue_or_a_title_for_a_new_one() {
1572+ let claim = |argv: &[&str]| {
1573+ let mut all = vec!["g1t", "work", "claim"];
1574+ all.extend_from_slice(argv);
1575+ Cli::try_parse_from(all).map(|cli| match cli.command {
1576+ super::Command::Work(super::WorkCommand::Claim(args)) => args,
1577+ _ => panic!("not work claim"),
1578+ })
1579+ };
1580+ let by_number = claim(&["12", "--note", "on it"]).unwrap();
1581+ assert_eq!((by_number.number, by_number.title), (Some(12), None));
1582+ let new = claim(&["--title", "Preview builds time out", "--body", "On repositories over 1 GB."]).unwrap();
1583+ assert_eq!(new.number, None);
1584+ assert_eq!(new.title.as_deref(), Some("Preview builds time out"));
1585+ assert_eq!(new.body.as_deref(), Some("On repositories over 1 GB."));
1586+ assert!(claim(&[]).is_err());
1587+ assert!(claim(&["12", "--title", "x"]).is_err());
1588+ // --body goes with --title: claim() refuses it with a number.
1589+ }
1590+
1591+ #[test]
1592+ fn a_session_takes_a_title_on_its_own() {
1593+ let cli = Cli::try_parse_from(["g1t", "session", "attach", "--title", "Fix the preview builds"]).unwrap();
1594+ let super::Command::Session(super::SessionCommand::Attach(args)) = cli.command else {
1595+ panic!("not session attach")
1596+ };
1597+ assert_eq!(args.title.as_deref(), Some("Fix the preview builds"));
1598+ assert!(Cli::try_parse_from(["g1t", "session", "attach"]).is_err());
1599+ }
15531600 }
+7−1
192192 .field_opt("pull", args.pull)
193193 .field_opt("branch", args.branch.clone())
194194 .field_opt("head_commit", args.head_commit.clone())
195+ .field_opt("title", args.title.clone())
195196 .send()?;
196197 let session = session_of(found);
197198 if ctx.out.structured() {
198199 return ctx.out.emit(&session);
199200 }
200− eprintln!("Session {id} is on {}.", place(&session));
201+ if args.issue.is_some() || args.pull.is_some() || args.branch.is_some() || args.head_commit.is_some() {
202+ eprintln!("Session {id} is on {}.", place(&session));
203+ }
204+ if args.title.is_some() {
205+ eprintln!("Session {id} is titled {}.", s(&session, "title"));
206+ }
201207 Ok(())
202208 }
203209
+38−8
257257 }
258258
259259 fn claim(ctx: &Ctx, args: WorkClaimArgs) -> Result<()> {
260+ if args.number.is_some() && args.body.is_some() {
261+ return Err(Error::usage("--body is the new issue's description: give it with --title, not a number."));
262+ }
260263 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
261− let found = client
262− .op("claim_issue")
263− .repo(&repo)
264− .arg("number", args.number)
264+ // A number claims that issue; a title opens one and claims it.
265+ let request = match (args.number, &args.title) {
266+ (Some(number), _) => client.op("claim_issue").repo(&repo).arg("number", number),
267+ (None, Some(title)) => client
268+ .op("claim_new_issue")
269+ .repo(&repo)
270+ .field("title", title.as_str())
271+ .field_opt("body", args.body.clone()),
272+ (None, None) => return Err(Error::usage("Name the issue by its number, or give --title to open one.")),
273+ };
274+ let found = request
265275 .field_opt("session_id", super::session::current())
266276 .field_opt("note", args.note.clone())
267277 .field_opt("ttl_minutes", args.ttl)
271281 }
272282 let out = &ctx.out;
273283 let claim = &found["claim"];
284+ let number = claimed_number(args.number, &found);
285+ if args.number.is_none() {
286+ println!("Opened #{number} {}.", s(&found["issue"], "title"));
287+ }
274288 match claim["expires_at"].as_str() {
275− Some(expires) => println!("Claimed #{}; it expires {} unless renewed.", args.number, until(expires)),
276− None => println!("Claimed #{}.", args.number),
289+ Some(expires) => println!("Claimed #{number}; it expires {} unless renewed.", until(expires)),
290+ None => println!("Claimed #{number}."),
277291 }
278292 let others = list_of(&found, "others");
279293 if !others.is_empty() {
280− println!("{}", out.yellow(&format!("#{} is claimed by others too:", args.number)));
294+ println!("{}", out.yellow(&format!("#{number} is claimed by others too:")));
281295 for other in others {
282296 println!(" {}", claim_line(out, other));
283297 }
287301 .filter(|b| !matches!(b["state"].as_str(), Some("closed" | "merged")))
288302 .collect();
289303 if !waiting.is_empty() {
290− println!("{}", out.yellow(&format!("#{} waits on:", args.number)));
304+ println!("{}", out.yellow(&format!("#{number} waits on:")));
291305 for blocker in waiting {
292306 println!(" {}", blocker_line(out, blocker));
293307 }
295309 Ok(())
296310 }
297311
312+/// The issue a claim is on: the number given, or the one it opened.
313+fn claimed_number(given: Option<u64>, found: &Value) -> u64 {
314+ given
315+ .or_else(|| found["claim"]["issue"].as_u64())
316+ .or_else(|| found["issue"]["number"].as_u64())
317+ .unwrap_or_default()
318+}
319+
298320 /// The time a dispatch may take: starting a Managed Agents session makes
299321 /// several calls to the provider.
300322 const DISPATCH_TIMEOUT: Duration = Duration::from_secs(60);
746768 use super::*;
747769
748770 #[test]
771+ fn a_claim_says_which_issue_it_is_on() {
772+ assert_eq!(claimed_number(Some(12), &json!({ "claim": { "issue": 12 } })), 12);
773+ // Opened for it: the claim names it, and so does the issue opened.
774+ assert_eq!(claimed_number(None, &json!({ "claim": { "issue": 31 }, "issue": { "number": 31 } })), 31);
775+ assert_eq!(claimed_number(None, &json!({ "issue": { "number": 31 } })), 31);
776+ }
777+
778+ #[test]
749779 fn blocking_adds_and_unblocking_removes() {
750780 assert_eq!(blocked_set(12, &[9], &[10, 9], true).unwrap(), vec![9, 10]);
751781 assert_eq!(blocked_set(12, &[9, 10], &[9], false).unwrap(), vec![10]);
+1−1
249249 Mapping { command: "session view", ops: &["get_session"] },
250250 Mapping { command: "session touch", ops: &["record_session_entries"] },
251251 Mapping { command: "work context", ops: &["get_work"] },
252− Mapping { command: "work claim", ops: &["claim_issue"] },
252+ Mapping { command: "work claim", ops: &["claim_issue", "claim_new_issue"] },
253253 Mapping { command: "work release", ops: &["release_claim"] },
254254 Mapping { command: "work handoff", ops: &["hand_off_issue"] },
255255 Mapping { command: "work list", ops: &["list_claims", "list_sessions"] },
+22−0
12991299 Param { name: "number", location: In::Path, kind: "integer", required: true, values: &[] },
13001300 ],
13011301 body: &[
1302+ Field { name: "body", kind: "string", required: false, values: &[] },
1303+ Field { name: "note", kind: "string", required: false, values: &[] },
1304+ Field { name: "session_id", kind: "string", required: false, values: &[] },
1305+ Field { name: "title", kind: "string", required: false, values: &[] },
1306+ Field { name: "ttl_minutes", kind: "integer", required: false, values: &[] },
1307+ ],
1308+ },
1309+ Operation {
1310+ id: "claim_new_issue",
1311+ method: "POST",
1312+ path: "/repos/{owner}/{name}/claims",
1313+ tag: "Claims and work",
1314+ summary: "Open an issue and claim it",
1315+ scope: Some("issues:write"),
1316+ params: &[
1317+ Param { name: "owner", location: In::Path, kind: "string", required: true, values: &[] },
1318+ Param { name: "name", location: In::Path, kind: "string", required: true, values: &[] },
1319+ ],
1320+ body: &[
1321+ Field { name: "body", kind: "string", required: false, values: &[] },
13021322 Field { name: "note", kind: "string", required: false, values: &[] },
1323+ Field { name: "number", kind: "integer", required: false, values: &[] },
13031324 Field { name: "session_id", kind: "string", required: false, values: &[] },
1325+ Field { name: "title", kind: "string", required: false, values: &[] },
13041326 Field { name: "ttl_minutes", kind: "integer", required: false, values: &[] },
13051327 ],
13061328 },
+2−0
10971097 model?: string | null;
10981098 /** How many subagents it started. */
10991099 subagents?: number;
1100+ /** In a list, for one with no title: the start of the first prompt it recorded. */
1101+ firstPrompt?: string | null;
11001102 };
11011103
11021104 /** What kind of thing a session touched. */
+1−1
11 {
22 "name": "g1t",
3− "version": "0.2.1",
3+ "version": "0.2.2",
44 "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.",
55 "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" },
66 "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/",
+18−5
11 ---
2−description: Claim a g1t issue for this session and load its work context
3−argument-hint: "<issue number> [note]"
2+description: Claim a g1t issue for this session and load its work context, or open one for the work in hand and claim it
3+argument-hint: "<issue number> [note] | <title of a new issue>"
44 allowed-tools: Bash(g1t:*)
55 ---
66
7−Claim g1t issue $1 for this session.
7+Claim a g1t issue for this session: $ARGUMENTS
8+
9+If the first argument is an issue number:
810
911 1. Run `g1t work context $1` and read it: the definition of done, who else
1012 has claimed it, its pull requests and what blocks it.
11−2. Run `g1t work claim $1`, adding `--note "..."` with the rest of these
12− arguments if there are any: $ARGUMENTS
13+2. Run `g1t work claim $1`, adding `--note "..."` with the rest of the
14+ arguments if there are any.
1315 3. Tell me in a few lines what the issue asks for, whether anyone else holds
1416 a claim on it (the claim command lists them), and anything that blocks
1517 it. If someone else is on it, ask me before starting work.
18+
19+If there is no number (or no arguments), this is the work in hand, which
20+has no issue yet:
21+
22+1. Look for an issue that already covers it: `g1t work list` and
23+ `g1t issue list`. If one does, claim that one as above.
24+2. Otherwise run `g1t work claim --title "TITLE"`, with the arguments as the
25+ title (or, with none, a short title for what this session is doing), and
26+ `--body "..."` saying what the change is. It opens the issue, claims it
27+ for this session and attaches the session to it.
28+3. Tell me the issue's number and title.
+2−2
55
66 Run `g1t status` and `g1t work list`, then summarise in a short list:
77
8−- this session (`G1T_SESSION`, if set), the issue it claimed and its pull request;
8+- this session (`G1T_SESSION`, if set): its title, the issue it claimed and its pull request, or, with no issue, its branch;
99 - every active claim in the repository: issue, who, how long ago and its note;
10−- other active sessions, and any of them working on the same issue as this one.
10+- other active sessions, and any of them working on the same issue, branch or pull request as this one.
1111
1212 Point out anything that needs me: two claims on one issue, a claim of mine
1313 about to expire, or a pull request of mine with failing checks.
+70−2
99 // issue, g1t work context N, given to Claude as context
1010 // UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters)
1111 // and, for a plugin's skill typed as /plugin:name,
12−// g1t session log --kind skill NAME
12+// g1t session log --kind skill NAME; at the session's
13+// first prompt that is not a slash command, its intent
14+// as the title, unless it has one: g1t session heartbeat
15+// --json, then g1t session attach --title INTENT
1316 // PreToolUse Agent: the task it is given, kept until its subagent starts.
1417 // Inside a subagent, its own g1t session for the g1t
1518 // commands (Bash or PowerShell) and g1t MCP claims it makes.
5457 export const SUMMARY_LIMIT = 200;
5558 /** The most of an issue's work context given to Claude. */
5659 export const CONTEXT_LIMIT = 8000;
60+/** The most of a prompt's intent that titles a session. */
61+export const TITLE_LIMIT = 80;
5762 /** The most of a subagent's task (its description) that is sent. */
5863 export const TASK_LIMIT = 200;
5964 /** The most of a subagent's prompt that is sent. */
195200 .trim();
196201 }
197202
203+/**
204+ * What a prompt is for, as a session's title: its first line or sentence,
205+ * without code blocks or markdown, whitespace collapsed, clipped at
206+ * TITLE_LIMIT on a word with "…". Null for a slash command (/clear,
207+ * /g1t:status 4), a turn Claude Code starts itself, or nothing to say.
208+ * apps/web/app/lib/coordination.ts reads an untitled session's first
209+ * prompt the same way.
210+ */
211+export function intentTitle(prompt) {
212+ let text = String(prompt ?? "").replace(/^\uFEFF/, "");
213+ if (/^\s*\//.test(text) || /^\s*<[a-z][\w-]*>/i.test(text)) return null;
214+ text = text
215+ .replace(/```[\s\S]*?(```|$)/g, "\n")
216+ .replace(/~~~[\s\S]*?(~~~|$)/g, "\n")
217+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
218+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
219+ .replace(/`([^`]*)`/g, "$1");
220+ const line = text
221+ .split(/\r?\n/)
222+ .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").trim())
223+ .find((l) => /[\p{L}\p{N}]/u.test(l));
224+ if (!line) return null;
225+ // Its first sentence: up to a stop followed by a space.
226+ let intent = line.replace(/\s+/g, " ").split(/(?<=[.!?])\s+(?=\S)/)[0].trim();
227+ if (intent.length > TITLE_LIMIT) {
228+ const cut = intent.slice(0, TITLE_LIMIT - 1);
229+ const space = cut.lastIndexOf(" ");
230+ intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`;
231+ } else {
232+ intent = intent.replace(/[\s,;:.]+$/, "");
233+ }
234+ if (intent.length < 2) return null;
235+ return intent[0].toLocaleUpperCase() + intent.slice(1);
236+}
237+
198238 /** The plugin skill a prompt runs when it is typed as one: `g1t:work-on-issue` for `/g1t:work-on-issue 42`. */
199239 export function typedSkill(prompt) {
200240 const text = String(prompt ?? "").replace(/^/, "");
247287 // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent.
248288 const skill = typedSkill(prompt);
249289 if (skill) job.steps.push(skillStep(skill));
290+ // What it is for titles the session, until it has a title (see hook and work).
291+ const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(prompt);
292+ if (intent) job.steps.push({ title: intent });
250293 return job.steps.length ? job : null;
251294 }
252295 case "SubagentStart":
402445 id: join(dir, `${safe}.id`),
403446 issue: join(dir, `${safe}.issue`),
404447 turn: join(dir, `${safe}.turn`),
448+ titled: join(dir, `${safe}.titled`),
405449 pending: join(dir, `${safe}.pending.json`),
406450 agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`),
407451 };
530574 } catch {}
531575 }
532576
577+/** Whether a session, as `--json` prints it, has a title; unreadable reads as having one, so nothing is replaced. */
578+export function hasTitle(stdout) {
579+ try {
580+ const title = JSON.parse(stdout).title;
581+ return typeof title === "string" && title.trim() !== "";
582+ } catch {
583+ return true;
584+ }
585+}
586+
533587 /** The session id in a `g1t session start --json` answer. */
534588 function startedId(out) {
535589 try {
634688 if (branch && branch !== "HEAD") touch.push("--branch", branch);
635689 cli(bin, touch, opts);
636690 }
691+ } else if (step.title) {
692+ // Only a session with no title: one set before (by the agent, a
693+ // person, or this at an earlier prompt) is never replaced.
694+ const seen = cli(bin, ["session", "heartbeat", "--json"], opts);
695+ if (seen.status === EXIT_AUTH) return setProblem(env, "signed_out");
696+ if (seen.status === EXIT_USAGE) return setProblem(env, "refused", "session heartbeat");
697+ if (seen.status !== 0 || hasTitle(seen.stdout)) continue;
698+ const out = cli(bin, ["session", "attach", `--title=${step.title}`], opts);
699+ if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session attach");
637700 } else if (step.touchHead) {
638701 const head = git(job.cwd, ["rev-parse", "HEAD"]);
639702 if (head) cli(bin, ["session", "touch", "--commit", head], opts);
700763 context = `g1t: ${where} points at issue #${issue}. Load what is known about it with \`g1t work context ${issue}\`, and claim it with \`g1t work claim ${issue}\` before you start. ${TRAILER}`;
701764 }
702765 } else {
703− context = `g1t: this is a g1t repository. Before starting on an issue, load it with \`g1t work context N\` and claim it with \`g1t work claim N\`, so the people and agents also working here know. ${TRAILER}`;
766+ context = `g1t: this session is recorded on g1t and shows to the team, titled with what you are asked to do. Just work. If it turns into a change others should know about (a branch to push, a pull request), first look for its issue (\`g1t work list\`, \`g1t issue list\`) and claim it with \`g1t work claim N\`; if there is none, open and claim one with \`g1t work claim --title "…"\`. Never open issues for questions, exploration or trivial fixes. ${TRAILER}`;
704767 }
705768 if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context };
706769 return output;
771834 // Only sessions SessionStart found in a g1t working copy are recorded.
772835 if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null };
773836 for (const step of job.steps) if (step.skill) step.key = `skill:${safeName(step.skill)}:${turn}`;
837+ // The first prompt with an intent titles the session; later ones do not try.
838+ if (name === "UserPromptSubmit" && job.steps.some((step) => step.title)) {
839+ if (existsSync(files.titled)) job.steps = job.steps.filter((step) => !step.title);
840+ else write(files.titled, "1");
841+ }
774842 if (name === "SubagentStart") {
775843 const task = takeTask(files, job.agent.type);
776844 if (task) Object.assign(job.agent, { task: task.task, prompt: env.G1T_RECORD_PROMPTS === "0" ? null : task.prompt });
+9−0
1313
1414 Issue: $ARGUMENTS (if empty, use the issue this session claimed).
1515
16+No issue yet? The session is already recorded on g1t and shows to the
17+team with what it is for, so there is nothing to coordinate until the
18+work becomes a change others should know about (a branch to push, a pull
19+request). Then look for its issue first (`g1t work list`,
20+`g1t issue list`) and claim it with `g1t work claim N`; if there is none,
21+open and claim one in one step with `g1t work claim --title "…"`, which
22+attaches this session to it. Never open issues for questions,
23+exploration or trivial fixes.
24+
1625 1. **Follow its events in the background.** Start
1726 `g1t work events --follow --issue N` as a background command, and read
1827 its output between steps. Each line is one event. Add `--json` for one
+8−0
1616
1717 Issue: $ARGUMENTS (if empty, ask which issue, or list open ones with `g1t issue list`).
1818
19+If the work you were asked for has no issue yet and has become a change
20+others should know about, look for one first (`g1t work list`,
21+`g1t issue list`). If there is none, `g1t work claim --title "…"` (with
22+`--body "…"` for the details) opens it and claims it for this session in
23+one step, attaching the session to it: start at step 4 with its number.
24+Questions, exploration and trivial fixes need no issue; the session is
25+already recorded on g1t.
26+
1927 1. **Load the context.** Run `g1t work context N`. It shows the issue and
2028 its definition of done, who has claimed it, the sessions and pull
2129 requests already on it with their checks and overlaps, what blocks it,
+2−1
3333 "one_of": ["--file", "--commit", "--branch", "--issue"]
3434 },
3535 { "command": ["session", "end"], "flags": { "--reason": "text", "--result": "text" } },
36− { "command": ["session", "heartbeat"], "flags": {} },
36+ { "command": ["session", "heartbeat"], "flags": { "--json": "json" } },
37+ { "command": ["session", "attach"], "flags": { "--title": "text" }, "required": ["--title"] },
3738 { "command": ["work", "context"], "flags": {}, "positionals": ["number"] },
3839 {
3940 "command": ["api"],
+4−0
9393 const id = args.some((a) => a === "--parent" || a.startsWith("--parent=")) ? `ses_sub_${harness.split("/agent/")[1]}` : "ses_fake01";
9494 process.stdout.write(JSON.stringify({ id, state: "active" }));
9595 }
96+ // The session as it stands: titled when FAKE_G1T_TITLE says so.
97+ if (args[0] === "session" && args[1] === "heartbeat" && args.includes("--json")) {
98+ process.stdout.write(JSON.stringify({ id: process.env.G1T_SESSION, state: "active", title: process.env.FAKE_G1T_TITLE || null }));
99+ }
96100 if (args[0] === "work" && args[1] === "context") process.stdout.write(`#${args[2]} The issue's title\nDefinition of done: it works.\n`);
97101 }
98102
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…