Merge branch 'worktree-agent-aef0bb4ceda89e542'
39 files+708−987 files not counted0/39 viewed
| 7 | 7 | "name": "g1t", | |
| 8 | 8 | "source": "./plugins/claude-code", | |
| 9 | 9 | "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", | |
| 11 | 11 | "category": "development" | |
| 12 | 12 | } | |
| 13 | 13 | ] |
| 102 | 102 | 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.", | |
| 103 | 103 | 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.", | |
| 104 | 104 | 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.", | |
| 106 | 106 | 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.", | |
| 107 | 107 | 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.", | |
| 108 | 108 | 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.", | |
| ⋯ | |||
| 231 | 231 | CoordinationOp::ClaimIssue => ( | |
| 232 | 232 | json!({ | |
| 233 | 233 | "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." }, | |
| 236 | 238 | "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." }, | |
| 237 | 239 | "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." }, | |
| 238 | 240 | }), | |
| 239 | − | &["repo", "number"], | |
| 241 | + | &["repo"], | |
| 240 | 242 | ), | |
| 241 | 243 | CoordinationOp::ReleaseClaim => ( | |
| 242 | 244 | json!({ | |
| ⋯ | |||
| 433 | 435 | Ok(minutes) => minutes, | |
| 434 | 436 | Err(message) => return invalid(message), | |
| 435 | 437 | }; | |
| 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 | + | } | |
| 436 | 445 | let args = ClaimArgs { | |
| 437 | 446 | actor: actor(), | |
| 438 | 447 | repo, | |
| 439 | 448 | number: issue_number, | |
| 449 | + | title, | |
| 450 | + | body: text(input, "body"), | |
| 440 | 451 | session_id: text(input, "session_id"), | |
| 441 | 452 | note: text(input, "note"), | |
| 442 | 453 | ttl_minutes, | |
| ⋯ | |||
| 554 | 565 | let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" })); | |
| 555 | 566 | assert_eq!(op, coordination(CoordinationOp::ClaimIssue)); | |
| 556 | 567 | 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" })); | |
| 557 | 572 | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({})); | |
| 558 | 573 | assert_eq!(op, coordination(CoordinationOp::ReleaseClaim)); | |
| 559 | 574 | let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" })); | |
| ⋯ | |||
| 573 | 588 | assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession))); | |
| 574 | 589 | assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries))); | |
| 575 | 590 | 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))); | |
| 576 | 593 | assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork))); | |
| 577 | 594 | } | |
| 578 | 595 | ||
| 925 | 925 | ("DELETE", "snooze") => "unsnooze_thread".to_owned(), | |
| 926 | 926 | // An issue's decisions, by its number. | |
| 927 | 927 | ("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(), | |
| 928 | 930 | _ => op.name().to_owned(), | |
| 929 | 931 | }; | |
| 930 | 932 | if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) { | |
| ⋯ | |||
| 954 | 956 | "unsave_thread" => "Unsave a thread", | |
| 955 | 957 | "unsnooze_thread" => "Bring a snoozed thread back", | |
| 956 | 958 | "list_issue_decisions" => "List an issue's decisions", | |
| 959 | + | "claim_new_issue" => "Open an issue and claim it", | |
| 957 | 960 | _ => title(route.op), | |
| 958 | 961 | }; | |
| 959 | 962 | if id.ends_with("_for_workspace") { | |
| 613 | 613 | // sessions of people's own harnesses. | |
| 614 | 614 | route("GET", "/repos/:owner/:name/issues/:number/work", Op::Coordination(CoordinationOp::GetWork), &[]), | |
| 615 | 615 | 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), &[]), | |
| 616 | 618 | route("POST", "/repos/:owner/:name/issues/:number/release", Op::Coordination(CoordinationOp::ReleaseClaim), &[]), | |
| 617 | 619 | route("POST", "/repos/:owner/:name/issues/:number/handoff", Op::Coordination(CoordinationOp::HandOffIssue), &[]), | |
| 618 | 620 | route( |
| 92 | 92 | 6. `pull_request` with `get`, to check `overlaps`, `behind` and the checks. | |
| 93 | 93 | 7. `pull_request` with `ready` and a summary of what changed and why. | |
| 94 | 94 | ||
| 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 | + | ||
| 95 | 113 | The call that starts the pull request: | |
| 96 | 114 | ||
| 97 | 115 | ```json | |
| ⋯ | |||
| 172 | 190 | ``` | |
| 173 | 191 | ||
| 174 | 192 | 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 | |
| 179 | 198 | everything it records, and how to opt out. | |
| 180 | 199 | ||
| 181 | 200 | Other agents record a session with the same CLI commands, from their | |
| 7 | 7 | machine to the work on g1t. With it installed, working in a g1t repository: | |
| 8 | 8 | ||
| 9 | 9 | - 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; | |
| 12 | 13 | - Claude starts each session knowing the issue it is on: its definition of | |
| 13 | 14 | done, who has claimed it, its pull requests and what blocks it; | |
| 14 | 15 | - Claude has g1t's [MCP tools](/reference/mcp/), three skills for working | |
| ⋯ | |||
| 67 | 68 | | --- | --- | --- | | |
| 68 | 69 | | 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. | | |
| 69 | 70 | | 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. | | |
| 70 | 72 | | 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 `…`. | | |
| 71 | 73 | | 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. | | |
| 72 | 74 | | 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. | | |
| ⋯ | |||
| 113 | 115 | recorded on it and the last integration failure. If the answer takes longer | |
| 114 | 116 | than a second, Claude is told to load it itself. | |
| 115 | 117 | ||
| 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). | |
| 117 | 121 | ||
| 118 | 122 | Claude is also told to put the trailer `G1t-Session: $G1T_SESSION` on the | |
| 119 | 123 | commits it makes, so g1t ties them, and the pull request they land in, to | |
| ⋯ | |||
| 123 | 127 | Anthropic connection, or `g1t work dispatch N --handoff`) runs as | |
| 124 | 128 | `G1T_ISSUE=N claude '…'`: the plugin attaches it to issue N as it starts. | |
| 125 | 129 | ||
| 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 | + | ||
| 126 | 161 | ## Subagents | |
| 127 | 162 | ||
| 128 | 163 | Each subagent Claude Code starts is recorded as a session of its own, | |
| ⋯ | |||
| 178 | 213 | ||
| 179 | 214 | | Command | What it does | | |
| 180 | 215 | | --- | --- | | |
| 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. | | |
| 183 | 218 | | `/g1t:handoff N [@login]` | Pushes what is unpushed, writes a hand-off note and hands the issue off, to anyone or to one person. | | |
| 184 | 219 | ||
| 185 | 220 | A claim says you are working on an issue. It is never a lock: a second | |
| ⋯ | |||
| 249 | 284 | This repository is on g1t. Other agents and people may be working in it at | |
| 250 | 285 | the same time; the `g1t` CLI is how you see them and how they see you. | |
| 251 | 286 | ||
| 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"` | |
| 253 | 289 | (or `cursor`, `copilot`, `other`: the harness you are). The CLI remembers | |
| 254 | 290 | it for this working copy. Run `g1t session heartbeat` every few minutes | |
| 255 | 291 | 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. | |
| 256 | 298 | - Before working on an issue, load it with `g1t work context N`: its | |
| 257 | 299 | definition of done, who has claimed it, its pull requests, checks and | |
| 258 | 300 | overlaps, and what blocks it. Then claim it with | |
| 346 | 346 | | `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`). | | |
| 347 | 347 | | `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. | | |
| 348 | 348 | | `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. | | |
| 350 | 350 | | `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. | | |
| 351 | 351 | | `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. | | |
| 352 | 352 | | `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. | | |
| ⋯ | |||
| 361 | 361 | ```sh | |
| 362 | 362 | g1t work context 12 # everything about #12's work | |
| 363 | 363 | 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." | |
| 364 | 365 | g1t work release 12 --reason done | |
| 365 | 366 | g1t work handoff 12 --to ana --note "Parser done; lexer tests still fail." | |
| 366 | 367 | g1t work list # active claims and sessions here | |
| ⋯ | |||
| 382 | 383 | current session's, so two sessions of yours (sibling subagents) each hold | |
| 383 | 384 | their own. | |
| 384 | 385 | ||
| 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 | + | ||
| 385 | 393 | ### Starting a session on an issue | |
| 386 | 394 | ||
| 387 | 395 | ```sh | |
| 14 | 14 | - **Claims**: an advisory, expiring "I'm working on #12". Never a lock. | |
| 15 | 15 | - **Dependencies**: the issues that have to land before this one. | |
| 16 | 16 | ||
| 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 | + | ||
| 17 | 23 | Nothing here waits on a harness. A harness that crashes, loses its | |
| 18 | 24 | network or sends the same thing twice does no harm: sessions and claims | |
| 19 | 25 | that go quiet are read as idle, ended or expired the next time anyone | |
| ⋯ | |||
| 28 | 34 | - **The Work page**, `g1t.sh/<workspace>/<repo>/work`, a tab beside | |
| 29 | 35 | **Pull requests**, **Merge queue** and **Sessions**: | |
| 30 | 36 | - **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**. | |
| 33 | 46 | - **Merge queue**: what is waiting to land and what is being tested, | |
| 34 | 47 | as on the [Merge queue](/guides/merge-queue/#the-merge-queue-page) page. | |
| 35 | 48 | - **What happened**: the latest 30 coordination events, from claims to | |
| ⋯ | |||
| 103 | 116 | `cursor`, `copilot` or `other`) and the harness's own id for the session as | |
| 104 | 117 | `harness_session_id`. Starting again with the same id returns the | |
| 105 | 118 | 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). | |
| 107 | 122 | 2. **Heartbeat** with `session` `update`, or | |
| 108 | 123 | [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/) | |
| 109 | 124 | 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`. | |
| 111 | 127 | 3. **Record** what happens with `session` `record`, or | |
| 112 | 128 | [`POST /repos/{owner}/{name}/sessions/{id}/entries`](/reference/api/sessions/record-session-entries/): | |
| 113 | 129 | prompts, reasoning, tool calls, notes and the skills it used (`skill`, | |
| ⋯ | |||
| 146 | 162 | [`GET /repos/{owner}/{name}/sessions`](/reference/api/sessions/list-sessions/) | |
| 147 | 163 | by `state`, `issue` or `pull` (any session tied to it), or `path` (those | |
| 148 | 164 | 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`. | |
| 151 | 169 | ||
| 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 | + | ||
| 152 | 192 | ## Subagents | |
| 153 | 193 | ||
| 154 | 194 | A harness that hands part of its work to a subagent (Claude Code's | |
| ⋯ | |||
| 277 | 317 | The answer has your `claim`, the `others` already on the issue, and | |
| 278 | 318 | `blocked_by`: each issue this one waits on, with its claims and pull | |
| 279 | 319 | 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. | |
| 280 | 327 | - **Renew** it by claiming again, or give the `session_id` of your session | |
| 281 | 328 | when you claim, and that session's heartbeats renew it. | |
| 282 | 329 | - **Release** it with `issue` `release`, or | |
| ⋯ | |||
| 293 | 340 | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 294 | 341 | -H "Content-Type: application/json" \ | |
| 295 | 342 | -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"}' | |
| 296 | 348 | ``` | |
| 297 | 349 | ||
| 298 | 350 | | | | | |
| 299 | 351 | | --- | --- | | |
| 300 | 352 | | 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`. | | |
| 301 | 354 | | When it ends | Released, handed off, its issue closed, or not renewed in time (`expired`). | | |
| 302 | 355 | | States | `active`, `released`, `expired`, `handed_off`. | | |
| 303 | 356 | ||
| ⋯ | |||
| 372 | 425 | | To | You need | | |
| 373 | 426 | | --- | --- | | |
| 374 | 427 | | 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. | | |
| 376 | 429 | | Update, record in or end a session | To be the person whose token started it. | | |
| 377 | 430 | | Release a claim or hand it off | To be the person who made it. | | |
| 378 | 431 | | Set `blocked_by` | To be the issue's author, or the Triage role. | | |
| 944 | 944 | ||
| 945 | 945 | ### g1t session attach | |
| 946 | 946 | ||
| 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. | |
| 948 | 948 | ||
| 949 | 949 | ```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>> | |
| 951 | 951 | ``` | |
| 952 | 952 | ||
| 953 | 953 | | Argument or flag | | | |
| ⋯ | |||
| 956 | 956 | | `-p, --pull <PULL>` | | | |
| 957 | 957 | | `-b, --branch <BRANCH>` | | | |
| 958 | 958 | | `--head-commit <SHA>` | The commit it pushed last. | | |
| 959 | + | | `-t, --title <TITLE>` | What it is for, in a few words; replaces its title. | | |
| 959 | 960 | ||
| 960 | 961 | Calls [`update_session`](/reference/api/sessions/update-session/). | |
| 961 | 962 | ||
| ⋯ | |||
| 1053 | 1054 | Claim an issue: say you are working on it. Others are told, never stopped. | |
| 1054 | 1055 | ||
| 1055 | 1056 | ```text | |
| 1056 | − | g1t work claim [OPTIONS] <NUMBER> | |
| 1057 | + | g1t work claim [OPTIONS] <NUMBER|--title <TITLE>> | |
| 1057 | 1058 | ``` | |
| 1058 | 1059 | ||
| 1059 | 1060 | | Argument or flag | | | |
| 1060 | 1061 | | --- | --- | | |
| 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. | | |
| 1062 | 1065 | | `-n, --note <NOTE>` | What you are doing about it. | | |
| 1063 | 1066 | | `--ttl <DURATION>` | How long the claim lasts unless renewed: 30m, 2h (at most 4h). | | |
| 1064 | 1067 | ||
| 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/). | |
| 1066 | 1077 | ||
| 1067 | 1078 | ### g1t work release | |
| 1068 | 1079 | ||
| 346 | 346 | | [`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` | | |
| 347 | 347 | | [`import`](/reference/api/integrations/import-issue/) | Open an issue from a Jira, Linear or Sentry item, linked to it. | `repo`, `reference` | `issues:write` | | |
| 348 | 348 | | [`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` | | |
| 350 | 350 | | [`release`](/reference/api/claims-and-work/release-claim/) | Give up your claim, with an optional `reason`. | `repo`, `number` | `issues:write` | | |
| 351 | 351 | | [`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` | | |
| 352 | 352 | | [`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` | |
Binary or large file; its contents are not shown.
| 37 | 37 | type Decision, | |
| 38 | 38 | type DecisionConfidence, | |
| 39 | 39 | type Harness, | |
| 40 | + | type Pull, | |
| 40 | 41 | type SessionState, | |
| 41 | 42 | type State, | |
| 42 | 43 | type WorkContext, | |
| ⋯ | |||
| 54 | 55 | evidenceOf, | |
| 55 | 56 | expiresIn, | |
| 56 | 57 | harnessName, | |
| 58 | + | type LiveGroup, | |
| 57 | 59 | type LiveIssue, | |
| 58 | 60 | liveByIssue, | |
| 59 | 61 | mergeSessions, | |
| 60 | 62 | numberedParts, | |
| 63 | + | sessionTitle, | |
| 61 | 64 | sessionTree, | |
| 62 | 65 | } from "../lib/coordination"; | |
| 63 | 66 | import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui"; | |
| ⋯ | |||
| 74 | 77 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 75 | 78 | import { Textarea } from "./ui/textarea"; | |
| 76 | 79 | import { PersonLink } from "./work"; | |
| 77 | − | import { IssueIcon } from "./work-icons"; | |
| 80 | + | import { IssueIcon, PullIcon } from "./work-icons"; | |
| 78 | 81 | ||
| 79 | 82 | /** Text with each `#N` linked to that issue or pull request (the issue's address leads to a pull request too). */ | |
| 80 | 83 | export function NumberedText({ text, base }: { text: string; base: string }) { | |
| ⋯ | |||
| 189 | 192 | <div className="min-w-0 grow"> | |
| 190 | 193 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"> | |
| 191 | 194 | <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)} | |
| 193 | 196 | </Link> | |
| 194 | 197 | <HarnessBadge harness={session.harness} /> | |
| 195 | 198 | <SessionStateBadge state={session.state} /> | |
| ⋯ | |||
| 783 | 786 | ); | |
| 784 | 787 | } | |
| 785 | 788 | ||
| 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 | + | ||
| 786 | 846 | /** | |
| 787 | 847 | * 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. | |
| 790 | 851 | */ | |
| 791 | 852 | export function LiveNowList({ | |
| 792 | 853 | claims, | |
| 793 | 854 | sessions, | |
| 794 | 855 | titles, | |
| 856 | + | pulls = {}, | |
| 857 | + | defaultBranch, | |
| 795 | 858 | base, | |
| 796 | 859 | now, | |
| 797 | 860 | limit, | |
| ⋯ | |||
| 799 | 862 | claims: Claim[]; | |
| 800 | 863 | sessions: WorkSession[]; | |
| 801 | 864 | 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; | |
| 802 | 869 | base: string; | |
| 803 | 870 | now: number; | |
| 804 | 871 | limit?: number; | |
| 805 | 872 | }) { | |
| 806 | − | const live = liveByIssue(claims, sessions); | |
| 873 | + | const live = liveByIssue(claims, sessions, defaultBranch); | |
| 807 | 874 | const issues = limit == null ? live.issues : live.issues.slice(0, limit); | |
| 808 | 875 | return ( | |
| 809 | 876 | <ol className="space-y-3"> | |
| 810 | 877 | {issues.map((group) => ( | |
| 811 | 878 | <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} /> | |
| 812 | 879 | ))} | |
| 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 | + | ))} | |
| 825 | 888 | </ol> | |
| 826 | 889 | ); | |
| 827 | 890 | } | |
| 9 | 9 | expiresIn, | |
| 10 | 10 | feedLine, | |
| 11 | 11 | harnessName, | |
| 12 | + | intentTitle, | |
| 12 | 13 | liveByIssue, | |
| 14 | + | sessionTitle, | |
| 13 | 15 | duration, | |
| 14 | 16 | mergeSessions, | |
| 15 | 17 | numberedParts, | |
| ⋯ | |||
| 117 | 119 | assert.deepEqual(loose.map((one) => one.id), ["ses_b"]); | |
| 118 | 120 | }); | |
| 119 | 121 | ||
| 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 | + | ||
| 120 | 171 | test("evidence is read as far as it goes", () => { | |
| 121 | 172 | const failed = decision("integration_failure", "2026-10-12T10:00:00Z", { | |
| 122 | 173 | 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 }, | |
| 83 | 83 | ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." }, | |
| 84 | 84 | }; | |
| 85 | 85 | ||
| 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 | + | ||
| 86 | 140 | // ── Live now ──────────────────────────────────────────────────────────── | |
| 87 | 141 | ||
| 88 | 142 | /** One issue being worked on: its claims and the sessions on it. */ | |
| 89 | 143 | export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] }; | |
| 90 | 144 | ||
| 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 | + | ||
| 91 | 153 | /** | |
| 92 | 154 | * 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. | |
| 94 | 160 | */ | |
| 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[] } { | |
| 96 | 166 | const byIssue = new Map<number, LiveIssue>(); | |
| 97 | 167 | const at = (issue: number) => { | |
| 98 | 168 | let group = byIssue.get(issue); | |
| ⋯ | |||
| 100 | 170 | return group; | |
| 101 | 171 | }; | |
| 102 | 172 | 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[] = []; | |
| 104 | 176 | for (const session of sessions) { | |
| 105 | 177 | 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); | |
| 107 | 187 | } | |
| 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 | + | }; | |
| 115 | 198 | } | |
| 116 | 199 | ||
| 117 | 200 | // ── Decisions ─────────────────────────────────────────────────────────── | |
| 33 | 33 | import { githubApp } from "../../lib/github.server"; | |
| 34 | 34 | import { ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 35 | 35 | import { Card } from "../../components/ui/card"; | |
| 36 | − | import { LiveNowList, StartHere } from "../../components/coordination"; | |
| 36 | + | import { type LivePull, LiveNowList, StartHere } from "../../components/coordination"; | |
| 37 | 37 | import { liveByIssue } from "../../lib/coordination"; | |
| 38 | 38 | import { type Need, ageBuckets, firstPassRate, passRate, rankNeeds, runHealth } from "../../lib/mission"; | |
| 39 | 39 | import { count, duration, percent } from "../../lib/observability"; | |
| ⋯ | |||
| 250 | 250 | // --- Live now ------------------------------------------------------------------ | |
| 251 | 251 | const titles: Record<number, string> = {}; | |
| 252 | 252 | 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; | |
| 253 | 258 | ||
| 254 | 259 | // --- Where it runs ------------------------------------------------------------ | |
| 255 | 260 | const environmentList = ok(environments); | |
| ⋯ | |||
| 308 | 313 | sessions: [...(ok(active) ?? []), ...(ok(idle) ?? [])], | |
| 309 | 314 | liveLoaded: claims?.ok === true || active?.ok === true, | |
| 310 | 315 | titles, | |
| 316 | + | livePulls, | |
| 317 | + | defaultBranch, | |
| 311 | 318 | health: { | |
| 312 | 319 | // A pull request's checks when any finished lately; otherwise the | |
| 313 | 320 | // project's workflow runs, for one that ships straight to its branch. | |
| ⋯ | |||
| 784 | 791 | actionData: Route.ComponentProps["actionData"]; | |
| 785 | 792 | params: Route.ComponentProps["params"]; | |
| 786 | 793 | }) { | |
| 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; | |
| 788 | 795 | const base = `/${params.owner}/${params.repo}`; | |
| 789 | 796 | const name = project?.name ?? params.repo; | |
| 790 | 797 | const plan = project ? projectPlan(project) : "production"; | |
| ⋯ | |||
| 899 | 906 | <Unavailable what="Who is working" /> | |
| 900 | 907 | ) : working ? ( | |
| 901 | 908 | <> | |
| 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 | + | /> | |
| 903 | 919 | {moreIssues > 0 && ( | |
| 904 | 920 | <Link to={`${base}/work`} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg"> | |
| 905 | 921 | {plural(moreIssues, "more issue")} being worked on | |
| 17 | 17 | import { PersonLink } from "../../components/work"; | |
| 18 | 18 | import { IssueIcon, PullIcon } from "../../components/work-icons"; | |
| 19 | 19 | 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"; | |
| 21 | 21 | import { page } from "../../lib/meta"; | |
| 22 | 22 | import { notFound } from "../../lib/not-found.server"; | |
| 23 | 23 | import { repoPages } from "../../lib/repo-live"; | |
| ⋯ | |||
| 27 | 27 | ||
| 28 | 28 | const MANAGED = "claude_managed_agents"; | |
| 29 | 29 | ||
| 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); | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 37 | 39 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 38 | 40 | 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)} · ` : ""; | |
| 40 | 43 | return page(args, { title: `${name}Session · ${params.owner}/${params.repo} · g1t` }); | |
| 41 | 44 | } | |
| 42 | 45 | ||
| ⋯ | |||
| 257 | 260 | useRepoLive({ match: repoPages.session() }); | |
| 258 | 261 | ||
| 259 | 262 | const tools = entries.filter((entry) => entry.kind === "tool_call").length; | |
| 263 | + | const firstPrompt = entries.find((entry) => entry.kind === "prompt")?.text; | |
| 260 | 264 | const isSubagent = Boolean(session.parentId); | |
| 261 | 265 | const parent = isSubagent && root?.id === session.parentId ? root : null; | |
| 262 | 266 | // Its own subagents: those whose parent is in this tree (children lists every descendant). | |
| ⋯ | |||
| 298 | 302 | ||
| 299 | 303 | <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 300 | 304 | <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> | |
| 302 | 306 | {isSubagent && session.agentType && ( | |
| 303 | 307 | <Badge tone="accent"> | |
| 304 | 308 | <Bot size={11} /> | |
| 5 | 5 | import type { EventType, Pull } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/work"; | |
| 8 | − | import { CoordinationFeed, LiveNowList, StartHere } from "../../components/coordination"; | |
| 8 | + | import { CoordinationFeed, type LivePull, LiveNowList, StartHere } from "../../components/coordination"; | |
| 9 | 9 | import { QueueTrain } from "../../components/merge-queue"; | |
| 10 | 10 | import { EmptyState, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { Avatar } from "../../components/ui/avatar"; | |
| ⋯ | |||
| 32 | 32 | const { viewer, repo } = await requireRepo(context, params, "read"); | |
| 33 | 33 | const path = { namespace: params.owner, name: params.repo }; | |
| 34 | 34 | // 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([ | |
| 36 | 36 | work.listClaims(path, viewer).catch(() => null), | |
| 37 | 37 | work.listWorkSessions(path, viewer, { state: "active" }).catch(() => null), | |
| 38 | 38 | work.listWorkSessions(path, viewer, { state: "idle" }).catch(() => null), | |
| ⋯ | |||
| 47 | 47 | // The titles of the issues being worked on. | |
| 48 | 48 | work.listIssues(path, viewer, { state: "open" }).catch(() => null), | |
| 49 | 49 | 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), | |
| 50 | 52 | ]); | |
| 51 | 53 | const titles: Record<number, string> = {}; | |
| 52 | 54 | 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 }; | |
| 53 | 57 | const landed = (closed?.ok ? closed.value : []) | |
| 54 | 58 | .filter((pull) => pull.status === "merged" && pull.mergedAt) | |
| 55 | 59 | .sort((a, b) => Date.parse(b.mergedAt!) - Date.parse(a.mergedAt!)) | |
| ⋯ | |||
| 60 | 64 | queue: queue?.ok ? queue.value : null, | |
| 61 | 65 | feed: recent.flatMap((event) => feedLine(event) ?? []), | |
| 62 | 66 | titles, | |
| 67 | + | pulls, | |
| 63 | 68 | landed, | |
| 64 | 69 | defaultBranch: repo.defaultBranch, | |
| 65 | 70 | }; | |
| ⋯ | |||
| 113 | 118 | } | |
| 114 | 119 | ||
| 115 | 120 | 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; | |
| 117 | 122 | const base = `/${params.owner}/${params.repo}`; | |
| 118 | 123 | const now = Date.now(); | |
| 119 | 124 | const working = claims.length > 0 || sessions.length > 0; | |
| ⋯ | |||
| 145 | 150 | {!working ? ( | |
| 146 | 151 | <StartHere title="Nobody is working on this repository right now" /> | |
| 147 | 152 | ) : ( | |
| 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} /> | |
| 149 | 154 | )} | |
| 150 | 155 | </Section> | |
| 151 | 156 | ||
| 195 | 195 | /// How many subagents it started. | |
| 196 | 196 | #[serde(default)] | |
| 197 | 197 | 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>, | |
| 198 | 203 | } | |
| 199 | 204 | ||
| 205 | + | /// The most characters of an untitled session's first prompt a list gives. | |
| 206 | + | pub const MAX_FIRST_PROMPT_CHARS: usize = 300; | |
| 207 | + | ||
| 200 | 208 | /// The most characters a subagent's task keeps. | |
| 201 | 209 | pub const MAX_TASK_CHARS: usize = 200; | |
| 202 | 210 | /// The most characters of a subagent's prompt kept. | |
| ⋯ | |||
| 803 | 811 | } | |
| 804 | 812 | ||
| 805 | 813 | /// `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)] | |
| 808 | 818 | pub struct ClaimArgs { | |
| 809 | 819 | pub actor: User, | |
| 810 | 820 | pub repo: RepoPath, | |
| 821 | + | /// The issue to claim; 0 with `title` to open one and claim it. | |
| 822 | + | #[serde(default)] | |
| 811 | 823 | 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>, | |
| 812 | 831 | #[serde(default)] | |
| 813 | 832 | pub session_id: Option<String>, | |
| 814 | 833 | #[serde(default)] | |
| ⋯ | |||
| 825 | 844 | pub others: Vec<Claim>, | |
| 826 | 845 | /// What the issue is blocked by, and where each stands. | |
| 827 | 846 | 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>, | |
| 828 | 851 | } | |
| 829 | 852 | ||
| 830 | 853 | /// `release_claim`: gives up your active claim on an issue. Returns | |
| 954 | 954 | Heartbeat, | |
| 955 | 955 | /// Record a note, prompt or message in the current session. | |
| 956 | 956 | 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. | |
| 958 | 958 | Attach(SessionAttachArgs), | |
| 959 | 959 | /// End the current session. | |
| 960 | 960 | End(SessionEndArgs), | |
| ⋯ | |||
| 1066 | 1066 | } | |
| 1067 | 1067 | ||
| 1068 | 1068 | #[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"])))] | |
| 1070 | 1070 | pub struct SessionAttachArgs { | |
| 1071 | 1071 | #[arg(short, long)] | |
| 1072 | 1072 | pub issue: Option<u64>, | |
| ⋯ | |||
| 1077 | 1077 | /// The commit it pushed last. | |
| 1078 | 1078 | #[arg(long, value_name = "SHA")] | |
| 1079 | 1079 | 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>, | |
| 1080 | 1083 | } | |
| 1081 | 1084 | ||
| 1082 | 1085 | #[derive(Debug, Args)] | |
| ⋯ | |||
| 1128 | 1131 | /// Everything about an issue's work: claims, sessions, pull requests, dependencies, decisions. | |
| 1129 | 1132 | Context(WorkNumber), | |
| 1130 | 1133 | /// 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\"")] | |
| 1131 | 1139 | Claim(WorkClaimArgs), | |
| 1132 | 1140 | /// Release your claim on an issue. | |
| 1133 | 1141 | Release(WorkReleaseArgs), | |
| ⋯ | |||
| 1206 | 1214 | } | |
| 1207 | 1215 | ||
| 1208 | 1216 | #[derive(Debug, Args)] | |
| 1217 | + | #[command(group(clap::ArgGroup::new("which").required(true).args(["number", "title"])))] | |
| 1209 | 1218 | pub struct WorkClaimArgs { | |
| 1210 | 1219 | /// 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>, | |
| 1212 | 1227 | /// What you are doing about it. | |
| 1213 | 1228 | #[arg(short, long)] | |
| 1214 | 1229 | pub note: Option<String>, | |
| ⋯ | |||
| 1550 | 1565 | assert_eq!(args.kind.name(), "tool_call"); | |
| 1551 | 1566 | } | |
| 1552 | 1567 | } | |
| 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 | + | } | |
| 1553 | 1600 | } | |
| 192 | 192 | .field_opt("pull", args.pull) | |
| 193 | 193 | .field_opt("branch", args.branch.clone()) | |
| 194 | 194 | .field_opt("head_commit", args.head_commit.clone()) | |
| 195 | + | .field_opt("title", args.title.clone()) | |
| 195 | 196 | .send()?; | |
| 196 | 197 | let session = session_of(found); | |
| 197 | 198 | if ctx.out.structured() { | |
| 198 | 199 | return ctx.out.emit(&session); | |
| 199 | 200 | } | |
| 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 | + | } | |
| 201 | 207 | Ok(()) | |
| 202 | 208 | } | |
| 203 | 209 |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | 259 | 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 | + | } | |
| 260 | 263 | 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 | |
| 265 | 275 | .field_opt("session_id", super::session::current()) | |
| 266 | 276 | .field_opt("note", args.note.clone()) | |
| 267 | 277 | .field_opt("ttl_minutes", args.ttl) | |
| ⋯ | |||
| 271 | 281 | } | |
| 272 | 282 | let out = &ctx.out; | |
| 273 | 283 | 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 | + | } | |
| 274 | 288 | 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}."), | |
| 277 | 291 | } | |
| 278 | 292 | let others = list_of(&found, "others"); | |
| 279 | 293 | 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:"))); | |
| 281 | 295 | for other in others { | |
| 282 | 296 | println!(" {}", claim_line(out, other)); | |
| 283 | 297 | } | |
| ⋯ | |||
| 287 | 301 | .filter(|b| !matches!(b["state"].as_str(), Some("closed" | "merged"))) | |
| 288 | 302 | .collect(); | |
| 289 | 303 | if !waiting.is_empty() { | |
| 290 | − | println!("{}", out.yellow(&format!("#{} waits on:", args.number))); | |
| 304 | + | println!("{}", out.yellow(&format!("#{number} waits on:"))); | |
| 291 | 305 | for blocker in waiting { | |
| 292 | 306 | println!(" {}", blocker_line(out, blocker)); | |
| 293 | 307 | } | |
| ⋯ | |||
| 295 | 309 | Ok(()) | |
| 296 | 310 | } | |
| 297 | 311 | ||
| 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 | + | ||
| 298 | 320 | /// The time a dispatch may take: starting a Managed Agents session makes | |
| 299 | 321 | /// several calls to the provider. | |
| 300 | 322 | const DISPATCH_TIMEOUT: Duration = Duration::from_secs(60); | |
| ⋯ | |||
| 746 | 768 | use super::*; | |
| 747 | 769 | ||
| 748 | 770 | #[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] | |
| 749 | 779 | fn blocking_adds_and_unblocking_removes() { | |
| 750 | 780 | assert_eq!(blocked_set(12, &[9], &[10, 9], true).unwrap(), vec![9, 10]); | |
| 751 | 781 | assert_eq!(blocked_set(12, &[9, 10], &[9], false).unwrap(), vec![10]); | |
| 249 | 249 | Mapping { command: "session view", ops: &["get_session"] }, | |
| 250 | 250 | Mapping { command: "session touch", ops: &["record_session_entries"] }, | |
| 251 | 251 | 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"] }, | |
| 253 | 253 | Mapping { command: "work release", ops: &["release_claim"] }, | |
| 254 | 254 | Mapping { command: "work handoff", ops: &["hand_off_issue"] }, | |
| 255 | 255 | Mapping { command: "work list", ops: &["list_claims", "list_sessions"] }, |
| 1299 | 1299 | Param { name: "number", location: In::Path, kind: "integer", required: true, values: &[] }, | |
| 1300 | 1300 | ], | |
| 1301 | 1301 | 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: &[] }, | |
| 1302 | 1322 | Field { name: "note", kind: "string", required: false, values: &[] }, | |
| 1323 | + | Field { name: "number", kind: "integer", required: false, values: &[] }, | |
| 1303 | 1324 | Field { name: "session_id", kind: "string", required: false, values: &[] }, | |
| 1325 | + | Field { name: "title", kind: "string", required: false, values: &[] }, | |
| 1304 | 1326 | Field { name: "ttl_minutes", kind: "integer", required: false, values: &[] }, | |
| 1305 | 1327 | ], | |
| 1306 | 1328 | }, |
| 1097 | 1097 | model?: string | null; | |
| 1098 | 1098 | /** How many subagents it started. */ | |
| 1099 | 1099 | subagents?: number; | |
| 1100 | + | /** In a list, for one with no title: the start of the first prompt it recorded. */ | |
| 1101 | + | firstPrompt?: string | null; | |
| 1100 | 1102 | }; | |
| 1101 | 1103 | ||
| 1102 | 1104 | /** What kind of thing a session touched. */ |
| 1 | 1 | { | |
| 2 | 2 | "name": "g1t", | |
| 3 | − | "version": "0.2.1", | |
| 3 | + | "version": "0.2.2", | |
| 4 | 4 | "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.", | |
| 5 | 5 | "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" }, | |
| 6 | 6 | "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/", |
| 1 | 1 | --- | |
| 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>" | |
| 4 | 4 | allowed-tools: Bash(g1t:*) | |
| 5 | 5 | --- | |
| 6 | 6 | ||
| 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: | |
| 8 | 10 | ||
| 9 | 11 | 1. Run `g1t work context $1` and read it: the definition of done, who else | |
| 10 | 12 | 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. | |
| 13 | 15 | 3. Tell me in a few lines what the issue asks for, whether anyone else holds | |
| 14 | 16 | a claim on it (the claim command lists them), and anything that blocks | |
| 15 | 17 | 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. |
| 5 | 5 | ||
| 6 | 6 | Run `g1t status` and `g1t work list`, then summarise in a short list: | |
| 7 | 7 | ||
| 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; | |
| 9 | 9 | - 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. | |
| 11 | 11 | ||
| 12 | 12 | Point out anything that needs me: two claims on one issue, a claim of mine | |
| 13 | 13 | about to expire, or a pull request of mine with failing checks. |
| 9 | 9 | // issue, g1t work context N, given to Claude as context | |
| 10 | 10 | // UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters) | |
| 11 | 11 | // 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 | |
| 13 | 16 | // PreToolUse Agent: the task it is given, kept until its subagent starts. | |
| 14 | 17 | // Inside a subagent, its own g1t session for the g1t | |
| 15 | 18 | // commands (Bash or PowerShell) and g1t MCP claims it makes. | |
| ⋯ | |||
| 54 | 57 | export const SUMMARY_LIMIT = 200; | |
| 55 | 58 | /** The most of an issue's work context given to Claude. */ | |
| 56 | 59 | export const CONTEXT_LIMIT = 8000; | |
| 60 | + | /** The most of a prompt's intent that titles a session. */ | |
| 61 | + | export const TITLE_LIMIT = 80; | |
| 57 | 62 | /** The most of a subagent's task (its description) that is sent. */ | |
| 58 | 63 | export const TASK_LIMIT = 200; | |
| 59 | 64 | /** The most of a subagent's prompt that is sent. */ | |
| ⋯ | |||
| 195 | 200 | .trim(); | |
| 196 | 201 | } | |
| 197 | 202 | ||
| 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 | + | ||
| 198 | 238 | /** The plugin skill a prompt runs when it is typed as one: `g1t:work-on-issue` for `/g1t:work-on-issue 42`. */ | |
| 199 | 239 | export function typedSkill(prompt) { | |
| 200 | 240 | const text = String(prompt ?? "").replace(/^/, ""); | |
| ⋯ | |||
| 247 | 287 | // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent. | |
| 248 | 288 | const skill = typedSkill(prompt); | |
| 249 | 289 | 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 }); | |
| 250 | 293 | return job.steps.length ? job : null; | |
| 251 | 294 | } | |
| 252 | 295 | case "SubagentStart": | |
| ⋯ | |||
| 402 | 445 | id: join(dir, `${safe}.id`), | |
| 403 | 446 | issue: join(dir, `${safe}.issue`), | |
| 404 | 447 | turn: join(dir, `${safe}.turn`), | |
| 448 | + | titled: join(dir, `${safe}.titled`), | |
| 405 | 449 | pending: join(dir, `${safe}.pending.json`), | |
| 406 | 450 | agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`), | |
| 407 | 451 | }; | |
| ⋯ | |||
| 530 | 574 | } catch {} | |
| 531 | 575 | } | |
| 532 | 576 | ||
| 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 | + | ||
| 533 | 587 | /** The session id in a `g1t session start --json` answer. */ | |
| 534 | 588 | function startedId(out) { | |
| 535 | 589 | try { | |
| ⋯ | |||
| 634 | 688 | if (branch && branch !== "HEAD") touch.push("--branch", branch); | |
| 635 | 689 | cli(bin, touch, opts); | |
| 636 | 690 | } | |
| 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"); | |
| 637 | 700 | } else if (step.touchHead) { | |
| 638 | 701 | const head = git(job.cwd, ["rev-parse", "HEAD"]); | |
| 639 | 702 | if (head) cli(bin, ["session", "touch", "--commit", head], opts); | |
| ⋯ | |||
| 700 | 763 | 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}`; | |
| 701 | 764 | } | |
| 702 | 765 | } 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}`; | |
| 704 | 767 | } | |
| 705 | 768 | if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context }; | |
| 706 | 769 | return output; | |
| ⋯ | |||
| 771 | 834 | // Only sessions SessionStart found in a g1t working copy are recorded. | |
| 772 | 835 | if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null }; | |
| 773 | 836 | 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 | + | } | |
| 774 | 842 | if (name === "SubagentStart") { | |
| 775 | 843 | const task = takeTask(files, job.agent.type); | |
| 776 | 844 | if (task) Object.assign(job.agent, { task: task.task, prompt: env.G1T_RECORD_PROMPTS === "0" ? null : task.prompt }); | |
| 13 | 13 | ||
| 14 | 14 | Issue: $ARGUMENTS (if empty, use the issue this session claimed). | |
| 15 | 15 | ||
| 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 | + | ||
| 16 | 25 | 1. **Follow its events in the background.** Start | |
| 17 | 26 | `g1t work events --follow --issue N` as a background command, and read | |
| 18 | 27 | its output between steps. Each line is one event. Add `--json` for one |
| 16 | 16 | ||
| 17 | 17 | Issue: $ARGUMENTS (if empty, ask which issue, or list open ones with `g1t issue list`). | |
| 18 | 18 | ||
| 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 | + | ||
| 19 | 27 | 1. **Load the context.** Run `g1t work context N`. It shows the issue and | |
| 20 | 28 | its definition of done, who has claimed it, the sessions and pull | |
| 21 | 29 | requests already on it with their checks and overlaps, what blocks it, |
| 33 | 33 | "one_of": ["--file", "--commit", "--branch", "--issue"] | |
| 34 | 34 | }, | |
| 35 | 35 | { "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"] }, | |
| 37 | 38 | { "command": ["work", "context"], "flags": {}, "positionals": ["number"] }, | |
| 38 | 39 | { | |
| 39 | 40 | "command": ["api"], |
| 93 | 93 | const id = args.some((a) => a === "--parent" || a.startsWith("--parent=")) ? `ses_sub_${harness.split("/agent/")[1]}` : "ses_fake01"; | |
| 94 | 94 | process.stdout.write(JSON.stringify({ id, state: "active" })); | |
| 95 | 95 | } | |
| 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 | + | } | |
| 96 | 100 | if (args[0] === "work" && args[1] === "context") process.stdout.write(`#${args[2]} The issue's title\nDefinition of done: it works.\n`); | |
| 97 | 101 | } | |
| 98 | 102 |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…