Skip to content

Commit

Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'

syntaqxcommitted Parents1f230ee0038405Browse files
44 files+1438−2528 files not counted0/44 viewed
+1−0
16461646 "g1t-rules",
16471647 "getrandom 0.2.17",
16481648 "hex",
1649+ "rusqlite",
16491650 "serde",
16501651 "serde_json",
16511652 "sha2 0.10.9",
+485−0
1+//! Coordinating work over REST and MCP: sessions of people's own harnesses
2+//! (the MCP `session` tool), and claims and an issue's whole context
3+//! (actions of the `issue` tool).
4+//!
5+//! The work service keeps them and decides who may see and change them
6+//! (`g1t_contracts::work::coordination`): anyone who can read the
7+//! repository reads them; taking part needs a confirmed email address and a
8+//! role that may open issues and pull requests; only the person whose
9+//! token started a session, or made a claim, changes it.
10+
11+use g1t_contracts::work::*;
12+use g1t_contracts::{FailureCode, Outcome, Viewer};
13+use serde_json::{Value, json};
14+use worker::Result;
15+
16+use crate::operations::{Services, repo_path};
17+
18+/// One operation on sessions, claims or an issue's context.
19+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20+pub enum CoordinationOp {
21+ StartSession,
22+ UpdateSession,
23+ EndSession,
24+ GetSession,
25+ ListSessions,
26+ RecordSessionEntries,
27+ ClaimIssue,
28+ ReleaseClaim,
29+ HandOffIssue,
30+ ListClaims,
31+ GetWork,
32+}
33+
34+impl CoordinationOp {
35+ /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a
36+ /// test checks against this.
37+ pub const ALL: [CoordinationOp; 11] = [
38+ CoordinationOp::StartSession,
39+ CoordinationOp::UpdateSession,
40+ CoordinationOp::EndSession,
41+ CoordinationOp::GetSession,
42+ CoordinationOp::ListSessions,
43+ CoordinationOp::RecordSessionEntries,
44+ CoordinationOp::ClaimIssue,
45+ CoordinationOp::ReleaseClaim,
46+ CoordinationOp::HandOffIssue,
47+ CoordinationOp::ListClaims,
48+ CoordinationOp::GetWork,
49+ ];
50+
51+ pub fn name(self) -> &'static str {
52+ match self {
53+ CoordinationOp::StartSession => "start_session",
54+ CoordinationOp::UpdateSession => "update_session",
55+ CoordinationOp::EndSession => "end_session",
56+ CoordinationOp::GetSession => "get_session",
57+ CoordinationOp::ListSessions => "list_sessions",
58+ CoordinationOp::RecordSessionEntries => "record_session_entries",
59+ CoordinationOp::ClaimIssue => "claim_issue",
60+ CoordinationOp::ReleaseClaim => "release_claim",
61+ CoordinationOp::HandOffIssue => "hand_off_issue",
62+ CoordinationOp::ListClaims => "list_claims",
63+ CoordinationOp::GetWork => "get_work",
64+ }
65+ }
66+
67+ /// For the API reference.
68+ pub fn title(self) -> &'static str {
69+ match self {
70+ CoordinationOp::StartSession => "Start a session",
71+ CoordinationOp::UpdateSession => "Update a session",
72+ CoordinationOp::EndSession => "End a session",
73+ CoordinationOp::GetSession => "Get a session",
74+ CoordinationOp::ListSessions => "List sessions",
75+ CoordinationOp::RecordSessionEntries => "Record entries in a session",
76+ CoordinationOp::ClaimIssue => "Claim an issue",
77+ CoordinationOp::ReleaseClaim => "Release a claim",
78+ CoordinationOp::HandOffIssue => "Hand off an issue",
79+ CoordinationOp::ListClaims => "List claims",
80+ CoordinationOp::GetWork => "Get an issue's work context",
81+ }
82+ }
83+
84+ /// Whether it only reads.
85+ pub fn reads(self) -> bool {
86+ matches!(
87+ self,
88+ CoordinationOp::GetSession | CoordinationOp::ListSessions | CoordinationOp::ListClaims | CoordinationOp::GetWork
89+ )
90+ }
91+
92+ pub fn description(self) -> &'static str {
93+ match self {
94+ CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the issue, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `issue` and `pull` attach it to an issue and a pull request of the repository; a pull request for an issue attaches that issue too. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open issues.",
95+ CoordinationOp::UpdateSession => "Update one of your sessions: attach it to an issue or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 2 hours; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. Returns the session; one ended with end_session is refused with `409`.",
96+ CoordinationOp::EndSession => "End one of your sessions, with an optional `reason`. A session.ended event. Ending one already ended returns it as it is. Its claims are not released: release them, hand the issue off, or let them expire.",
97+ 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. Anyone who can read the repository may.",
98+ 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, and `issue` or `pull` for those attached to one. Anyone who can read the repository may.",
99+ CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result or note), `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. 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`.",
100+ 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`.",
101+ 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.",
102+ 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.",
103+ 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.",
104+ CoordinationOp::GetWork => "Everything about an issue an agent needs before working on it, in one call: the `issue`, its `definition_of_done` (the items under its \"Definition of done\" heading) and the `required_checks` every pull request into the default branch must pass; active `claims`; `sessions` on it or its pull requests; its `pulls`, each with its status, required checks as they stand on its head, whether it merges cleanly, the other pull requests it overlaps and its place in the merge queue; `blocked_by` (each issue it waits on, with claims and pull requests) and `blocks` (the issues waiting on it); `decisions` recorded about it; `last_integration_failure`; and `recent`, its latest coordination events, newest first.",
105+ }
106+ }
107+
108+ pub fn input(self) -> Value {
109+ let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
110+ let id = json!({ "type": "string", "description": "The session's id (ses_…), from start_session or list_sessions." });
111+ let number = json!({ "type": "integer", "description": "The issue's number." });
112+ let issue = json!({ "type": "integer", "description": "The issue it works on." });
113+ let pull = json!({ "type": "integer", "description": "The pull request it works on." });
114+ let fields = |more: Value| -> Value {
115+ let mut properties = json!({
116+ "repo": repo,
117+ "issue": issue,
118+ "pull": pull,
119+ "branch": { "type": "string", "description": "The branch it works on." },
120+ "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." },
121+ "head_commit": { "type": "string", "description": "The commit it is at, such as after a push." },
122+ });
123+ if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) {
124+ all.extend(more);
125+ }
126+ properties
127+ };
128+ let (properties, required): (Value, &[&str]) = match self {
129+ CoordinationOp::StartSession => (
130+ fields(json!({
131+ "harness": {
132+ "type": "string",
133+ "enum": Harness::ALL.map(Harness::as_str),
134+ "description": "The harness the session runs in.",
135+ },
136+ "harness_session_id": {
137+ "type": "string",
138+ "description": "The harness's own id for the session, such as Claude Code's session_id. Starting again with it returns the same session.",
139+ },
140+ })),
141+ &["repo", "harness"],
142+ ),
143+ CoordinationOp::UpdateSession => (fields(json!({ "id": id })), &["repo", "id"]),
144+ CoordinationOp::EndSession => (
145+ json!({
146+ "repo": repo,
147+ "id": id,
148+ "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." },
149+ }),
150+ &["repo", "id"],
151+ ),
152+ CoordinationOp::GetSession => (
153+ json!({
154+ "repo": repo,
155+ "id": id,
156+ "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." },
157+ }),
158+ &["repo", "id"],
159+ ),
160+ CoordinationOp::ListSessions => (
161+ json!({
162+ "repo": repo,
163+ "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." },
164+ "issue": { "type": "integer", "description": "Only sessions on this issue." },
165+ "pull": { "type": "integer", "description": "Only sessions on this pull request." },
166+ }),
167+ &["repo"],
168+ ),
169+ CoordinationOp::RecordSessionEntries => (
170+ json!({
171+ "repo": repo,
172+ "id": id,
173+ "entries": {
174+ "type": "array",
175+ "description": "Up to 200 entries, oldest first.",
176+ "items": {
177+ "type": "object",
178+ "properties": {
179+ "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note"] },
180+ "text": { "type": "string" },
181+ "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." },
182+ "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." },
183+ "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." },
184+ },
185+ "required": ["kind", "text"],
186+ },
187+ },
188+ }),
189+ &["repo", "id", "entries"],
190+ ),
191+ CoordinationOp::ClaimIssue => (
192+ json!({
193+ "repo": repo,
194+ "number": number,
195+ "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim." },
196+ "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
197+ "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
198+ }),
199+ &["repo", "number"],
200+ ),
201+ CoordinationOp::ReleaseClaim => (
202+ json!({
203+ "repo": repo,
204+ "number": number,
205+ "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." },
206+ }),
207+ &["repo", "number"],
208+ ),
209+ CoordinationOp::HandOffIssue => (
210+ json!({
211+ "repo": repo,
212+ "number": number,
213+ "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." },
214+ "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." },
215+ }),
216+ &["repo", "number", "note"],
217+ ),
218+ CoordinationOp::ListClaims => (
219+ json!({
220+ "repo": repo,
221+ "state": {
222+ "type": "string",
223+ "enum": ["active", "released", "expired", "handed_off"],
224+ "description": "Claims in this state; active if left out.",
225+ },
226+ "issue": { "type": "integer", "description": "Only claims on this issue." },
227+ }),
228+ &["repo"],
229+ ),
230+ CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]),
231+ };
232+ json!({ "type": "object", "properties": properties, "required": required })
233+ }
234+}
235+
236+fn text(input: &Value, key: &str) -> Option<String> {
237+ input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned)
238+}
239+
240+/// A whole number given as a number, as digits, or as `#12`.
241+fn number(input: &Value, key: &str) -> Option<u32> {
242+ match &input[key] {
243+ Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
244+ Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(),
245+ _ => None,
246+ }
247+}
248+
249+/// An optional whole number: none when left out, refused when not one.
250+fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> {
251+ match &input[key] {
252+ Value::Null => Ok(None),
253+ Value::String(text) if text.trim().is_empty() => Ok(None),
254+ _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")),
255+ }
256+}
257+
258+/// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or
259+/// `#12`. None when left out; refused when it holds anything else.
260+pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> {
261+ let items = match &input["blocked_by"] {
262+ Value::Null => return Ok(None),
263+ Value::Array(items) => items,
264+ _ => return Err("blocked_by is a list of issue numbers.".to_owned()),
265+ };
266+ items
267+ .iter()
268+ .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number.")))
269+ .collect::<std::result::Result<Vec<u32>, String>>()
270+ .map(Some)
271+}
272+
273+pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
274+ let Some(repo) = repo_path(input) else {
275+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
276+ };
277+ let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message));
278+ let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) {
279+ (Ok(issue), Ok(pull)) => (issue, pull),
280+ (Err(message), _) | (_, Err(message)) => return invalid(message),
281+ };
282+ let actor = || viewer.clone().unwrap_or_default();
283+ let id = || text(input, "id").unwrap_or_default();
284+ let issue_number = number(input, "number").unwrap_or_default();
285+ let work = &services.work;
286+ if matches!(
287+ op,
288+ CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries
289+ ) && id().is_empty()
290+ {
291+ return invalid("Name the session by its id (ses_…).".to_owned());
292+ }
293+ match op {
294+ CoordinationOp::StartSession => {
295+ let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else {
296+ let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect();
297+ return invalid(format!("harness is one of {}.", names.join(", ")));
298+ };
299+ let args = StartSessionArgs {
300+ actor: actor(),
301+ repo,
302+ harness,
303+ harness_session_id: text(input, "harness_session_id"),
304+ issue,
305+ pull,
306+ branch: text(input, "branch"),
307+ title: text(input, "title"),
308+ head_commit: text(input, "head_commit"),
309+ };
310+ g1t_kit::call(work, "start_session", &args).await
311+ }
312+ CoordinationOp::UpdateSession => {
313+ let args = UpdateSessionArgs {
314+ actor: actor(),
315+ repo,
316+ id: id(),
317+ issue,
318+ pull,
319+ branch: text(input, "branch"),
320+ head_commit: text(input, "head_commit"),
321+ title: text(input, "title"),
322+ };
323+ g1t_kit::call(work, "update_session", &args).await
324+ }
325+ CoordinationOp::EndSession => {
326+ let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason") };
327+ g1t_kit::call(work, "end_session", &args).await
328+ }
329+ CoordinationOp::GetSession => {
330+ let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() };
331+ g1t_kit::call(work, "get_work_session", &args).await
332+ }
333+ CoordinationOp::ListSessions => {
334+ let state = match text(input, "state") {
335+ None => None,
336+ Some(state) => match SessionState::parse(&state) {
337+ Some(state) => Some(state),
338+ None => return invalid(format!("state is active, idle or ended, not {state}.")),
339+ },
340+ };
341+ let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull };
342+ g1t_kit::call(work, "list_work_sessions", &args).await
343+ }
344+ CoordinationOp::RecordSessionEntries => {
345+ let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(input["entries"].clone()) {
346+ Ok(entries) => entries,
347+ Err(_) => {
348+ return invalid(
349+ "entries is a list of { kind, text }, kind one of prompt, message, tool_call, tool_result or note, with tool, commit and key as needed.".to_owned(),
350+ );
351+ }
352+ };
353+ let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries };
354+ g1t_kit::call(work, "record_session_entries", &args).await
355+ }
356+ CoordinationOp::ClaimIssue => {
357+ let ttl_minutes = match optional_number(input, "ttl_minutes") {
358+ Ok(minutes) => minutes,
359+ Err(message) => return invalid(message),
360+ };
361+ let args = ClaimArgs {
362+ actor: actor(),
363+ repo,
364+ number: issue_number,
365+ session_id: text(input, "session_id"),
366+ note: text(input, "note"),
367+ ttl_minutes,
368+ };
369+ g1t_kit::call(work, "claim_issue", &args).await
370+ }
371+ CoordinationOp::ReleaseClaim => {
372+ let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason") };
373+ g1t_kit::call(work, "release_claim", &args).await
374+ }
375+ CoordinationOp::HandOffIssue => {
376+ let Some(note) = text(input, "note") else {
377+ return invalid("Say where the work stands in note, for whoever picks it up.".to_owned());
378+ };
379+ let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note };
380+ g1t_kit::call(work, "hand_off_issue", &args).await
381+ }
382+ CoordinationOp::ListClaims => {
383+ let state = match text(input, "state") {
384+ None => None,
385+ Some(state) => match ClaimState::parse(&state) {
386+ Some(state) => Some(state),
387+ None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")),
388+ },
389+ };
390+ let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue };
391+ g1t_kit::call(work, "list_claims", &args).await
392+ }
393+ CoordinationOp::GetWork => {
394+ let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number };
395+ g1t_kit::call(work, "get_work", &args).await
396+ }
397+ }
398+}
399+
400+#[cfg(test)]
401+mod tests {
402+ use super::*;
403+ use crate::operations::Op;
404+ use g1t_contracts::scopes::{Level, Scope, scope_for};
405+
406+ #[test]
407+ fn each_operation_is_described_scoped_and_listed() {
408+ for op in CoordinationOp::ALL {
409+ assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name());
410+ assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op)));
411+ assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name());
412+ assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
413+ let scope = scope_for(op.name()).unwrap();
414+ assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name());
415+ let sessions = op.name().contains("session");
416+ let expected = match (sessions, op.reads()) {
417+ (true, true) => Scope::PullRequestsRead,
418+ (true, false) => Scope::PullRequestsWrite,
419+ (false, true) => Scope::IssuesRead,
420+ (false, false) => Scope::IssuesWrite,
421+ };
422+ assert_eq!(scope, expected, "{}", op.name());
423+ }
424+ }
425+
426+ #[test]
427+ fn routes_reach_each_operation_with_its_input() {
428+ let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| {
429+ let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
430+ let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap();
431+ (route.op, input)
432+ };
433+ let coordination = |op| Op::Coordination(op);
434+ let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" }));
435+ assert_eq!(op, coordination(CoordinationOp::StartSession));
436+ assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" }));
437+ let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({}));
438+ assert_eq!(op, coordination(CoordinationOp::UpdateSession));
439+ assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" }));
440+ let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({}));
441+ assert_eq!(op, coordination(CoordinationOp::EndSession));
442+ let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null);
443+ assert_eq!(op, coordination(CoordinationOp::GetSession));
444+ assert_eq!(input["after"], "40");
445+ let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null);
446+ assert_eq!(op, coordination(CoordinationOp::ListSessions));
447+ assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" }));
448+ let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] }));
449+ assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries));
450+ let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" }));
451+ assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
452+ assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" }));
453+ let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({}));
454+ assert_eq!(op, coordination(CoordinationOp::ReleaseClaim));
455+ let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" }));
456+ assert_eq!(op, coordination(CoordinationOp::HandOffIssue));
457+ let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null);
458+ assert_eq!(op, coordination(CoordinationOp::ListClaims));
459+ let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null);
460+ assert_eq!(op, coordination(CoordinationOp::GetWork));
461+ assert_eq!(input, json!({ "number": 4, "repo": "acme/web" }));
462+ }
463+
464+ #[test]
465+ fn mcp_actions_reach_each_operation() {
466+ use crate::tools::{Tool, resolve};
467+ let call = |tool: &str, action: &str| resolve(Tool::by_name(tool).unwrap(), &json!({ "action": action, "repo": "a/b", "id": "ses_1", "number": 4, "harness": "codex", "entries": [], "note": "x" }));
468+ assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession)));
469+ assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
470+ assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries)));
471+ assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
472+ assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork)));
473+ }
474+
475+ #[test]
476+ fn numbers_come_as_numbers_digits_or_with_a_hash() {
477+ assert_eq!(blocked_by(&json!({})), Ok(None));
478+ assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5])));
479+ assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![])));
480+ assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err());
481+ assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err());
482+ assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None));
483+ assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err());
484+ }
485+}
+1−0
1414 mod blobs;
1515 mod broker;
1616 mod checks;
17+mod coordination;
1718 mod deployments;
1819 mod telemetry;
1920 mod deploy_keys;
+3−2
1111 const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
1212
1313 const INSTRUCTIONS: &str = "g1t is where your code, its history and the work around it live: repositories, issues, pull requests, workflows, deployments and packages. You bring your own agent; these tools are how it reads and changes what is on g1t, as the person or token it acts for. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers.
14−Tools are resources, each with an `action`: search, repository, issue, pull_request, memory, workflow, observability, package, secret, webhook, access, team, workspace, billing, security, notifications, account, artifact. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs.
14+Tools are resources, each with an `action`: search, repository, issue, pull_request, session, memory, workflow, observability, package, secret, webhook, access, team, workspace, billing, security, notifications, account, artifact. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs.
1515 Find a repository: account whoami lists your workspaces; repository list or search finds one.
16−Work on an issue: issue get (read it and the pull requests already made for it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again.
16+Work on an issue: issue work (everything about it: what done means, who is on it, its pull requests, what it waits on), issue claim (advisory: it tells you who else is on it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Push your commits, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again.
17+Coordinate: session start once per run of your harness (harness_session_id makes it idempotent), session update as a heartbeat, session record your prompts and tool calls, session end when done; issue release or handoff (with a note) when you stop.
1718 Record your session: as you work, pull_request record_session appends your prompts, reasoning and the tools you ran to the pull request's session, so reviewers see why the change is what it is; pull_request read_session reads it back. pull_request record_decision keeps a decision (why the change is made this way, or what fixed a failure) and list_decisions reads them; repository why says why a line of code is the way it is.
1819 Merge queue: when your pull request fails combined with the ones ahead of it, g1t says which of them it breaks with and the check that failed (on the pull request, its issue and its session, and as integration.failed). Fix it and push: once its checks pass, it rejoins the queue by itself.
1920 Memory: memory recall before you start to learn how the project builds and what it has decided; when you learn something the next agent needs, memory remember it, one short fact at a time (scope project or workspace). Never a secret.
+24−2
1515 use crate::telemetry::TelemetryOp;
1616 use crate::packages::PackagesOp;
1717 use crate::folios::FoliosOp;
18+use crate::coordination::CoordinationOp;
1819 use crate::protection::ProtectionOp;
1920 use crate::token_policy::TokenOp;
2021 use crate::operations::Op;
336337 ],
337338 ),
338339 (
340+ "Claims and work",
341+ "Coordinating work on issues: an advisory, expiring claim that says you are on one (never a lock), handing it off with a note, and everything an agent needs before starting on an issue in one call. What an issue is blocked by is set with update_issue.",
342+ &[
343+ Op::Coordination(CoordinationOp::GetWork),
344+ Op::Coordination(CoordinationOp::ClaimIssue),
345+ Op::Coordination(CoordinationOp::ReleaseClaim),
346+ Op::Coordination(CoordinationOp::HandOffIssue),
347+ Op::Coordination(CoordinationOp::ListClaims),
348+ ],
349+ ),
350+ (
339351 "Labels and milestones",
340352 "A repository's labels, which issues and pull requests carry by name, and its milestones, which gather them under a goal and a due date.",
341353 &[
373385 ),
374386 (
375387 "Sessions",
376− "The record of how a pull request was made: prompts, reasoning and the tools that ran.",
377− &[Op::ReadSession, Op::RecordSession],
388+ "Who is working on what, and how a pull request was made. A session is one run of your own harness (a Claude Code session, a Codex run) on a repository: started once, kept alive by heartbeats, attached to an issue and a pull request, and recording prompts, reasoning and the tools that ran. A pull request's session shows what was recorded on it.",
389+ &[
390+ Op::Coordination(CoordinationOp::StartSession),
391+ Op::Coordination(CoordinationOp::UpdateSession),
392+ Op::Coordination(CoordinationOp::EndSession),
393+ Op::Coordination(CoordinationOp::GetSession),
394+ Op::Coordination(CoordinationOp::ListSessions),
395+ Op::Coordination(CoordinationOp::RecordSessionEntries),
396+ Op::ReadSession,
397+ Op::RecordSession,
398+ ],
378399 ),
379400 (
380401 "Decisions and why",
780801 Op::DeployKeys(op) => op.title(),
781802 Op::Packages(op) => op.title(),
782803 Op::Folios(op) => op.title(),
804+ Op::Coordination(op) => op.title(),
783805 }
784806 }
785807
+35−2
3535 use crate::telemetry::TelemetryOp;
3636 use crate::packages::PackagesOp;
3737 use crate::folios::FoliosOp;
38+use crate::coordination::CoordinationOp;
3839 use crate::protection::ProtectionOp;
3940 use crate::token_policy::TokenOp;
4041 use crate::rules::RulesOp;
320321 /// Artifacts mode's docs, slides, designs and dashboards, kept by the
321322 /// artifacts service: folios.rs.
322323 Folios(FoliosOp),
324+ /// Coordinating work: harness sessions, claims on issues and an
325+ /// issue's whole context, kept by the work service: coordination.rs.
326+ Coordination(CoordinationOp),
323327 }
324328
325329 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
689693 }
690694
691695 impl Op {
692− pub const ALL: [Op; 356] = [
696+ pub const ALL: [Op; 367] = [
693697 Op::Whoami,
694698 Op::GetWorkspace,
695699 Op::CreateWorkspace,
10461050 Op::Folios(FoliosOp::RestoreVersion),
10471051 Op::Folios(FoliosOp::SetAccess),
10481052 Op::Folios(FoliosOp::Purge),
1053+ Op::Coordination(CoordinationOp::StartSession),
1054+ Op::Coordination(CoordinationOp::UpdateSession),
1055+ Op::Coordination(CoordinationOp::EndSession),
1056+ Op::Coordination(CoordinationOp::GetSession),
1057+ Op::Coordination(CoordinationOp::ListSessions),
1058+ Op::Coordination(CoordinationOp::RecordSessionEntries),
1059+ Op::Coordination(CoordinationOp::ClaimIssue),
1060+ Op::Coordination(CoordinationOp::ReleaseClaim),
1061+ Op::Coordination(CoordinationOp::HandOffIssue),
1062+ Op::Coordination(CoordinationOp::ListClaims),
1063+ Op::Coordination(CoordinationOp::GetWork),
10491064 ];
10501065
10511066 pub fn by_name(name: &str) -> Option<Op> {
12471262 Op::DeployKeys(op) => op.name(),
12481263 Op::Packages(op) => op.name(),
12491264 Op::Folios(op) => op.name(),
1265+ Op::Coordination(op) => op.name(),
12501266 }
12511267 }
12521268
13881404 "Open an issue on a repository. Say what done means in the body if it helps, for instance under a \"Definition of done\" heading; what must pass before a pull request for it merges is the default branch's required checks, the same for every pull request. labels are the repository's labels by name; a name it does not have yet is created when you have the Triage role or higher, and refused otherwise. milestone, a milestone's number, needs the Triage role."
13891405 }
13901406 Op::UpdateIssue => {
1391− "Change an issue's title, body, labels, milestone or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set, and milestone null or 0 takes it out of its milestone. Its author may change their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher, and so does the milestone. Each label added or removed is an issue.labeled or issue.unlabeled event."
1407+ "Change an issue's title, body, labels, milestone, the people it is assigned to, or what it is blocked by. Only the fields given are changed; labels, assignees and blocked_by each replace the whole set, and milestone null or 0 takes it out of its milestone. blocked_by lists the numbers of the issues of the same repository that have to land first: one that is not an issue there, the issue itself, or a set that would make a cycle is refused with `422`, and a change is a dependency.changed event (as is a blocker closing, or its pull request merging, later). Its author may change their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher, and so does the milestone. Each label added or removed is an issue.labeled or issue.unlabeled event."
13921408 }
13931409 Op::CloseIssue => {
13941410 "Close an issue without a pull request. Merging a pull request made for an issue closes it for you. Its author may close their own issue, as may the person g1t filed one for; anyone else needs the Triage role or higher."
17661782 Op::DeployKeys(op) => op.description(),
17671783 Op::Packages(op) => op.description(),
17681784 Op::Folios(op) => op.description(),
1785+ Op::Coordination(op) => op.description(),
17691786 }
17701787 }
17711788
22812298 "items": { "type": "string" },
22822299 "description": "Usernames of the people it is assigned to. Replaces the whole set; an empty list unassigns everyone.",
22832300 },
2301+ "blocked_by": {
2302+ "type": "array",
2303+ "items": { "type": "integer" },
2304+ "description": "The numbers of the issues of this repository that have to land first. Replaces the whole set; an empty list clears it. No cycles, and never the issue itself.",
2305+ },
22842306 })),
22852307 &["repo", "number"],
22862308 ),
31373159 Op::DeployKeys(op) => op.input(),
31383160 Op::Packages(op) => op.input(),
31393161 Op::Folios(op) => op.input(),
3162+ Op::Coordination(op) => op.input(),
31403163 }
31413164 }
31423165
31763199 if let Op::Packages(op) = self {
31773200 return !op.anonymous();
31783201 }
3202+ // And who is working on what in a public repository.
3203+ if let Op::Coordination(op) = self {
3204+ return !op.reads();
3205+ }
31793206 !matches!(
31803207 self,
31813208 Op::ListRepos
42154242 Ok(with_deprecation(opened, !checks.is_empty()))
42164243 }
42174244 Op::UpdateIssue => {
4245+ let blocked_by = match crate::coordination::blocked_by(input) {
4246+ Ok(blocked_by) => blocked_by,
4247+ Err(message) => return failed(FailureCode::Invalid, &message),
4248+ };
42184249 pass(
42194250 work,
42204251 "update_issue",
42274258 labels: strings(input, "labels"),
42284259 assignees: strings(input, "assignees"),
42294260 milestone: milestone_input(input),
4261+ blocked_by,
42304262 },
42314263 )
42324264 .await
53585390 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
53595391 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
53605392 Op::Folios(op) => crate::folios::run(op, services, viewer, input).await,
5393+ Op::Coordination(op) => crate::coordination::run(op, services, viewer, input).await,
53615394 Op::ReopenSecurityAlert => {
53625395 let changed: Outcome<AlertChange> = call(
53635396 &services.security,
+592−2
11861186 "assignees": [
11871187 "syntaqx"
11881188 ],
1189− "milestone": 3
1189+ "milestone": 3,
1190+ "blocked_by": [9]
11901191 },
11911192 "response": {
11921193 "id": "iss_01m43shrzpfe49x74ga7sj1c6v",
12181219 "assignees": [
12191220 "syntaqx"
12201221 ],
1221− "blocked_by": [],
1222+ "blocked_by": [9],
12221223 "queued": false,
12231224 "agent": null,
12241225 "milestone": {
22272228 },
22282229 "notes": "Up to 200 entries can be appended per request. Each is recorded against the pull request's head commit at the time. See [sessions and why-blame](/guides/why-blame/)."
22292230 },
2231+ "start_session": {
2232+ "request": {
2233+ "harness": "claude_code",
2234+ "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13",
2235+ "issue": 12,
2236+ "branch": "greet-caller",
2237+ "title": "Make the greeting name the caller"
2238+ },
2239+ "response": {
2240+ "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2241+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2242+ "issue": 12,
2243+ "pull": null,
2244+ "branch": "greet-caller",
2245+ "actor": {
2246+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2247+ "username": "syntaqx"
2248+ },
2249+ "harness": "claude_code",
2250+ "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13",
2251+ "title": "Make the greeting name the caller",
2252+ "state": "active",
2253+ "head_commit": null,
2254+ "started_at": "2026-10-12T14:02:11.204Z",
2255+ "last_seen_at": "2026-10-12T14:02:11.204Z",
2256+ "ended_at": null,
2257+ "end_reason": null
2258+ },
2259+ "notes": "Idempotent on `harness_session_id`: a second start with it returns this session, so call it from a hook without checking first. `state` is `active`, `idle` (no heartbeat for 15 minutes) or `ended` (ended, or no heartbeat for 2 hours: `end_reason` `timed_out`). See [Sessions](/guides/sessions/)."
2260+ },
2261+ "update_session": {
2262+ "request": {
2263+ "pull": 14,
2264+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13"
2265+ },
2266+ "response": {
2267+ "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2268+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2269+ "issue": 12,
2270+ "pull": 14,
2271+ "branch": "greet-caller",
2272+ "actor": {
2273+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2274+ "username": "syntaqx"
2275+ },
2276+ "harness": "claude_code",
2277+ "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13",
2278+ "title": "Make the greeting name the caller",
2279+ "state": "active",
2280+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2281+ "started_at": "2026-10-12T14:02:11.204Z",
2282+ "last_seen_at": "2026-10-12T14:31:40.518Z",
2283+ "ended_at": null,
2284+ "end_reason": null
2285+ },
2286+ "notes": "Send `{}` as a heartbeat. Each call renews the session's claims and brings back one that timed out. Refused with `404` for a session that is not yours, and `409` for one you ended."
2287+ },
2288+ "end_session": {
2289+ "request": {
2290+ "reason": "done"
2291+ },
2292+ "response": {
2293+ "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2294+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2295+ "issue": 12,
2296+ "pull": 14,
2297+ "branch": "greet-caller",
2298+ "actor": {
2299+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2300+ "username": "syntaqx"
2301+ },
2302+ "harness": "claude_code",
2303+ "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13",
2304+ "title": "Make the greeting name the caller",
2305+ "state": "ended",
2306+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2307+ "started_at": "2026-10-12T14:02:11.204Z",
2308+ "last_seen_at": "2026-10-12T14:40:03.115Z",
2309+ "ended_at": "2026-10-12T14:40:03.115Z",
2310+ "end_reason": "done"
2311+ }
2312+ },
2313+ "get_session": {
2314+ "query": {
2315+ "after": 0
2316+ },
2317+ "response": {
2318+ "session": {
2319+ "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2320+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2321+ "issue": 12,
2322+ "pull": 14,
2323+ "branch": "greet-caller",
2324+ "actor": {
2325+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2326+ "username": "syntaqx"
2327+ },
2328+ "harness": "claude_code",
2329+ "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13",
2330+ "title": "Make the greeting name the caller",
2331+ "state": "active",
2332+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2333+ "started_at": "2026-10-12T14:02:11.204Z",
2334+ "last_seen_at": "2026-10-12T14:31:40.518Z",
2335+ "ended_at": null,
2336+ "end_reason": null
2337+ },
2338+ "entries": [
2339+ {
2340+ "seq": 1,
2341+ "kind": "prompt",
2342+ "text": "Make the greeting name the caller (issue #12).",
2343+ "tool": null,
2344+ "commit": null,
2345+ "at": "2026-10-12T14:02:12.008Z"
2346+ },
2347+ {
2348+ "seq": 2,
2349+ "kind": "tool_call",
2350+ "text": "cargo test",
2351+ "tool": "Bash",
2352+ "commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2353+ "at": "2026-10-12T14:30:12.660Z"
2354+ }
2355+ ]
2356+ },
2357+ "notes": "Entries are numbered within the session and come 500 at a time; send the last `seq` you have as `after` for the next page."
2358+ },
2359+ "list_sessions": {
2360+ "query": {
2361+ "state": "active",
2362+ "issue": 12
2363+ },
2364+ "response": [
2365+ {
2366+ "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2367+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2368+ "issue": 12,
2369+ "pull": 14,
2370+ "branch": "greet-caller",
2371+ "actor": {
2372+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2373+ "username": "syntaqx"
2374+ },
2375+ "harness": "claude_code",
2376+ "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13",
2377+ "title": "Make the greeting name the caller",
2378+ "state": "active",
2379+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2380+ "started_at": "2026-10-12T14:02:11.204Z",
2381+ "last_seen_at": "2026-10-12T14:31:40.518Z",
2382+ "ended_at": null,
2383+ "end_reason": null
2384+ }
2385+ ]
2386+ },
2387+ "record_session_entries": {
2388+ "request": {
2389+ "entries": [
2390+ {
2391+ "kind": "prompt",
2392+ "text": "Make the greeting name the caller (issue #12).",
2393+ "key": "prompt-1"
2394+ },
2395+ {
2396+ "kind": "tool_call",
2397+ "text": "cargo test",
2398+ "tool": "Bash",
2399+ "key": "tool-7"
2400+ }
2401+ ]
2402+ },
2403+ "response": {
2404+ "recorded": 2,
2405+ "duplicates": 0
2406+ },
2407+ "notes": "Up to 200 entries a request. An entry whose `key` the session recorded before is counted in `duplicates` and not recorded again. Entries of a session on a pull request show in that pull request's session too (read_session)."
2408+ },
2409+ "claim_issue": {
2410+ "request": {
2411+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2412+ "note": "Taking the argument parsing first.",
2413+ "ttl_minutes": 30
2414+ },
2415+ "response": {
2416+ "claim": {
2417+ "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3",
2418+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2419+ "issue": 12,
2420+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2421+ "actor": {
2422+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2423+ "username": "syntaqx"
2424+ },
2425+ "harness": "claude_code",
2426+ "note": "Taking the argument parsing first.",
2427+ "state": "active",
2428+ "created_at": "2026-10-12T14:03:02.771Z",
2429+ "expires_at": "2026-10-12T15:01:40.518Z",
2430+ "ended_at": null,
2431+ "end_reason": null,
2432+ "handoff_to": null
2433+ },
2434+ "others": [
2435+ {
2436+ "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2",
2437+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2438+ "issue": 12,
2439+ "session_id": null,
2440+ "actor": {
2441+ "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e",
2442+ "username": "ana"
2443+ },
2444+ "harness": null,
2445+ "note": "Looking at the tests.",
2446+ "state": "active",
2447+ "created_at": "2026-10-12T13:50:12.000Z",
2448+ "expires_at": "2026-10-12T14:20:12.000Z",
2449+ "ended_at": null,
2450+ "end_reason": null,
2451+ "handoff_to": null
2452+ }
2453+ ],
2454+ "blocked_by": [
2455+ {
2456+ "number": 9,
2457+ "title": "Read arguments with a parser",
2458+ "state": "open",
2459+ "claims": [
2460+ {
2461+ "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2",
2462+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2463+ "issue": 9,
2464+ "session_id": null,
2465+ "actor": {
2466+ "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e",
2467+ "username": "ana"
2468+ },
2469+ "harness": null,
2470+ "note": null,
2471+ "state": "active",
2472+ "created_at": "2026-10-12T13:50:12.000Z",
2473+ "expires_at": "2026-10-12T14:20:12.000Z",
2474+ "ended_at": null,
2475+ "end_reason": null,
2476+ "handoff_to": null
2477+ }
2478+ ],
2479+ "pulls": [
2480+ {
2481+ "number": 11,
2482+ "title": "Parse arguments with clap",
2483+ "status": "open"
2484+ }
2485+ ]
2486+ }
2487+ ]
2488+ },
2489+ "notes": "A claim is advisory: it is made even when `others` lists someone already on the issue. Claiming an issue you hold renews your claim. Refused with `409` for a closed issue. See [Claims](/guides/sessions/#claims)."
2490+ },
2491+ "release_claim": {
2492+ "request": {
2493+ "reason": "Ana has it covered."
2494+ },
2495+ "response": {
2496+ "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3",
2497+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2498+ "issue": 12,
2499+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2500+ "actor": {
2501+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2502+ "username": "syntaqx"
2503+ },
2504+ "harness": "claude_code",
2505+ "note": "Taking the argument parsing first.",
2506+ "state": "released",
2507+ "created_at": "2026-10-12T14:03:02.771Z",
2508+ "expires_at": "2026-10-12T15:01:40.518Z",
2509+ "ended_at": "2026-10-12T14:12:30.000Z",
2510+ "end_reason": "Ana has it covered.",
2511+ "handoff_to": null
2512+ }
2513+ },
2514+ "hand_off_issue": {
2515+ "request": {
2516+ "to": "ana",
2517+ "note": "Parsing is done and tested; the README still needs the new usage."
2518+ },
2519+ "response": {
2520+ "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3",
2521+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2522+ "issue": 12,
2523+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2524+ "actor": {
2525+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2526+ "username": "syntaqx"
2527+ },
2528+ "harness": "claude_code",
2529+ "note": "Parsing is done and tested; the README still needs the new usage.",
2530+ "state": "handed_off",
2531+ "created_at": "2026-10-12T14:03:02.771Z",
2532+ "expires_at": "2026-10-12T15:01:40.518Z",
2533+ "ended_at": "2026-10-12T14:38:00.000Z",
2534+ "end_reason": "handed_off",
2535+ "handoff_to": "ana"
2536+ }
2537+ },
2538+ "list_claims": {
2539+ "query": {
2540+ "state": "active"
2541+ },
2542+ "response": [
2543+ {
2544+ "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3",
2545+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2546+ "issue": 12,
2547+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2548+ "actor": {
2549+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2550+ "username": "syntaqx"
2551+ },
2552+ "harness": "claude_code",
2553+ "note": "Taking the argument parsing first.",
2554+ "state": "active",
2555+ "created_at": "2026-10-12T14:03:02.771Z",
2556+ "expires_at": "2026-10-12T15:01:40.518Z",
2557+ "ended_at": null,
2558+ "end_reason": null,
2559+ "handoff_to": null
2560+ },
2561+ {
2562+ "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2",
2563+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2564+ "issue": 12,
2565+ "session_id": null,
2566+ "actor": {
2567+ "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e",
2568+ "username": "ana"
2569+ },
2570+ "harness": null,
2571+ "note": "Looking at the tests.",
2572+ "state": "active",
2573+ "created_at": "2026-10-12T13:50:12.000Z",
2574+ "expires_at": "2026-10-12T14:20:12.000Z",
2575+ "ended_at": null,
2576+ "end_reason": null,
2577+ "handoff_to": null
2578+ }
2579+ ]
2580+ },
2581+ "get_work": {
2582+ "response": {
2583+ "issue": {
2584+ "id": "iss_01m43shrzpfe49x74ga7sj1c6v",
2585+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2586+ "number": 12,
2587+ "title": "Greeting should name the caller",
2588+ "body": "Take a name from the first argument; fall back to world.\n\n## Definition of done\n\n- `cargo test` passes.\n- The README shows the new usage.",
2589+ "labels": [
2590+ "feature",
2591+ "good first issue"
2592+ ],
2593+ "state": "open",
2594+ "reason": null,
2595+ "resolved_by": null,
2596+ "author": {
2597+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2598+ "username": "syntaqx",
2599+ "display_username": "syntaqx",
2600+ "kind": "user",
2601+ "verified": false,
2602+ "workspaces": []
2603+ },
2604+ "requested_by": null,
2605+ "created_at": "2026-10-01T18:04:11.482Z",
2606+ "updated_at": "2026-10-01T18:09:47.305Z",
2607+ "closed_at": null,
2608+ "pull_count": 0,
2609+ "comment_count": 0,
2610+ "assignees": [
2611+ "syntaqx"
2612+ ],
2613+ "blocked_by": [
2614+ 9
2615+ ],
2616+ "queued": false,
2617+ "agent": null,
2618+ "milestone": {
2619+ "number": 3,
2620+ "title": "Launch"
2621+ }
2622+ },
2623+ "definition_of_done": [
2624+ "`cargo test` passes.",
2625+ "The README shows the new usage."
2626+ ],
2627+ "required_checks": [
2628+ "CI"
2629+ ],
2630+ "claims": [
2631+ {
2632+ "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3",
2633+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2634+ "issue": 12,
2635+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2636+ "actor": {
2637+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2638+ "username": "syntaqx"
2639+ },
2640+ "harness": "claude_code",
2641+ "note": "Taking the argument parsing first.",
2642+ "state": "active",
2643+ "created_at": "2026-10-12T14:03:02.771Z",
2644+ "expires_at": "2026-10-12T15:01:40.518Z",
2645+ "ended_at": null,
2646+ "end_reason": null,
2647+ "handoff_to": null
2648+ }
2649+ ],
2650+ "sessions": [
2651+ {
2652+ "id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2653+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2654+ "issue": 12,
2655+ "pull": 14,
2656+ "branch": "greet-caller",
2657+ "actor": {
2658+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2659+ "username": "syntaqx"
2660+ },
2661+ "harness": "claude_code",
2662+ "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13",
2663+ "title": "Make the greeting name the caller",
2664+ "state": "active",
2665+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2666+ "started_at": "2026-10-12T14:02:11.204Z",
2667+ "last_seen_at": "2026-10-12T14:31:40.518Z",
2668+ "ended_at": null,
2669+ "end_reason": null
2670+ }
2671+ ],
2672+ "pulls": [
2673+ {
2674+ "number": 14,
2675+ "title": "Greeting should name the caller",
2676+ "status": "draft",
2677+ "branch": "greet-caller",
2678+ "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2679+ "author": {
2680+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2681+ "username": "syntaqx"
2682+ },
2683+ "check_status": null,
2684+ "required_checks": [
2685+ {
2686+ "name": "CI",
2687+ "state": "success",
2688+ "description": "CI passed",
2689+ "target_url": "https://g1t.sh/syntaqx/hello/actions/runs/run_01m43sw8e2g6j0m4q8t2x6a0c4"
2690+ }
2691+ ],
2692+ "mergeable": "clean",
2693+ "overlaps": [
2694+ {
2695+ "number": 11,
2696+ "title": "Parse arguments with clap",
2697+ "issue": 9,
2698+ "paths": [
2699+ "src/main.rs"
2700+ ]
2701+ }
2702+ ],
2703+ "queue": null
2704+ }
2705+ ],
2706+ "blocked_by": [
2707+ {
2708+ "number": 9,
2709+ "title": "Read arguments with a parser",
2710+ "state": "open",
2711+ "claims": [
2712+ {
2713+ "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2",
2714+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2715+ "issue": 9,
2716+ "session_id": null,
2717+ "actor": {
2718+ "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e",
2719+ "username": "ana"
2720+ },
2721+ "harness": null,
2722+ "note": null,
2723+ "state": "active",
2724+ "created_at": "2026-10-12T13:50:12.000Z",
2725+ "expires_at": "2026-10-12T14:20:12.000Z",
2726+ "ended_at": null,
2727+ "end_reason": null,
2728+ "handoff_to": null
2729+ }
2730+ ],
2731+ "pulls": [
2732+ {
2733+ "number": 11,
2734+ "title": "Parse arguments with clap",
2735+ "status": "open"
2736+ }
2737+ ]
2738+ }
2739+ ],
2740+ "blocks": [
2741+ {
2742+ "number": 15,
2743+ "title": "Greet in the caller's language",
2744+ "state": "open"
2745+ }
2746+ ],
2747+ "decisions": [
2748+ {
2749+ "id": "dec_01m4b3e7g9j1l3n5q7s9u1w3y5",
2750+ "issue": 12,
2751+ "pull": 14,
2752+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2753+ "actor": "syntaqx",
2754+ "kind": "proposal",
2755+ "confidence": "claimed",
2756+ "summary": "Read the name with the argument parser #9 brings, rather than by hand.",
2757+ "evidence": {
2758+ "commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13"
2759+ },
2760+ "created_at": "2026-10-12T14:20:00.000Z"
2761+ }
2762+ ],
2763+ "last_integration_failure": null,
2764+ "recent": [
2765+ {
2766+ "id": "evt_01m4b3d5f7h9k1m3p5r7t9w1y4",
2767+ "type": "work.claimed",
2768+ "source": "work",
2769+ "time": "2026-10-12T14:03:02.771Z",
2770+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2771+ "actor": "usr_01kkntcg1eeb98j62xjm7eh09p",
2772+ "data": {
2773+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2774+ "number": 12,
2775+ "issue": 12,
2776+ "claim": {
2777+ "id": "clm_01m4b3d5f7h9k1m3p5r7t9w1y3",
2778+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2779+ "issue": 12,
2780+ "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2",
2781+ "actor": {
2782+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2783+ "username": "syntaqx"
2784+ },
2785+ "harness": "claude_code",
2786+ "note": "Taking the argument parsing first.",
2787+ "state": "active",
2788+ "created_at": "2026-10-12T14:03:02.771Z",
2789+ "expires_at": "2026-10-12T15:01:40.518Z",
2790+ "ended_at": null,
2791+ "end_reason": null,
2792+ "handoff_to": null
2793+ },
2794+ "others": [
2795+ {
2796+ "id": "clm_01m4b2a4c6e8g0j2l4n6q8s0u2",
2797+ "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
2798+ "issue": 12,
2799+ "session_id": null,
2800+ "actor": {
2801+ "id": "usr_01kkp2v7d4fh8m3q6t9w2z5c8e",
2802+ "username": "ana"
2803+ },
2804+ "harness": null,
2805+ "note": "Looking at the tests.",
2806+ "state": "active",
2807+ "created_at": "2026-10-12T13:50:12.000Z",
2808+ "expires_at": "2026-10-12T14:20:12.000Z",
2809+ "ended_at": null,
2810+ "end_reason": null,
2811+ "handoff_to": null
2812+ }
2813+ ]
2814+ }
2815+ }
2816+ ]
2817+ },
2818+ "notes": "`pulls` are the issue's pull requests, oldest first; `required_checks`, `mergeable`, `overlaps` and `queue` (its `state` and `position` in the merge queue, 1 at the front) are filled in for those still open or draft. `recent` holds up to 20 coordination events about the issue or its pull requests, newest first. See [Picking up an issue](/guides/sessions/#picking-up-an-issue)."
2819+ },
22302820 "record_decision": {
22312821 "request": {
22322822 "kind": "fix",
+11−0
1818 use crate::checks::ChecksOp;
1919 use crate::rules::RulesOp;
2020 use crate::deploy_keys::DeployKeysOp;
21+use crate::coordination::CoordinationOp;
2122
2223 /// A key as `#[serde(rename_all = "camelCase")]` writes it.
2324 fn camel_key(key: &str) -> String {
234235 Op::Checks(ChecksOp::ListCheckRunsForRef) => through::<checks::CheckRunList>(op, sent),
235236 Op::Checks(ChecksOp::ListCheckSuitesForRef) => through::<checks::CheckSuiteList>(op, sent),
236237 Op::Checks(ChecksOp::GetCheckSuite) => through::<checks::CommitCheckSuite>(op, sent),
238+ Op::Coordination(CoordinationOp::StartSession | CoordinationOp::UpdateSession | CoordinationOp::EndSession) => {
239+ through::<work::Session>(op, sent)
240+ }
241+ Op::Coordination(CoordinationOp::GetSession) => through::<work::SessionDetail>(op, sent),
242+ Op::Coordination(CoordinationOp::ListSessions) => through::<Vec<work::Session>>(op, sent),
243+ Op::Coordination(CoordinationOp::RecordSessionEntries) => through::<work::Recorded>(op, sent),
244+ Op::Coordination(CoordinationOp::ClaimIssue) => through::<work::Claimed>(op, sent),
245+ Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffIssue) => through::<work::Claim>(op, sent),
246+ Op::Coordination(CoordinationOp::ListClaims) => through::<Vec<work::Claim>>(op, sent),
247+ Op::Coordination(CoordinationOp::GetWork) => through::<work::WorkContext>(op, sent),
237248 _ => sent,
238249 }
239250 }
+29−0
1010 use crate::telemetry::TelemetryOp;
1111 use crate::packages::PackagesOp;
1212 use crate::folios::FoliosOp;
13+use crate::coordination::CoordinationOp;
1314 use crate::protection::ProtectionOp;
1415 use crate::token_policy::TokenOp;
1516 use crate::operations::Op;
592593 Op::ImportIssue,
593594 &[],
594595 ),
596+ // Coordinating work: claims on issues, an issue's whole context, and
597+ // sessions of people's own harnesses.
598+ route("GET", "/repos/:owner/:name/issues/:number/work", Op::Coordination(CoordinationOp::GetWork), &[]),
599+ route("POST", "/repos/:owner/:name/issues/:number/claim", Op::Coordination(CoordinationOp::ClaimIssue), &[]),
600+ route("POST", "/repos/:owner/:name/issues/:number/release", Op::Coordination(CoordinationOp::ReleaseClaim), &[]),
601+ route("POST", "/repos/:owner/:name/issues/:number/handoff", Op::Coordination(CoordinationOp::HandOffIssue), &[]),
602+ route(
603+ "GET",
604+ "/repos/:owner/:name/claims",
605+ Op::Coordination(CoordinationOp::ListClaims),
606+ &[("state", "state"), ("issue", "issue")],
607+ ),
608+ route("POST", "/repos/:owner/:name/sessions", Op::Coordination(CoordinationOp::StartSession), &[]),
609+ route(
610+ "GET",
611+ "/repos/:owner/:name/sessions",
612+ Op::Coordination(CoordinationOp::ListSessions),
613+ &[("state", "state"), ("issue", "issue"), ("pull", "pull")],
614+ ),
615+ route("GET", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::GetSession), &[("after", "after")]),
616+ route("PATCH", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::UpdateSession), &[]),
617+ route("POST", "/repos/:owner/:name/sessions/:id/end", Op::Coordination(CoordinationOp::EndSession), &[]),
618+ route(
619+ "POST",
620+ "/repos/:owner/:name/sessions/:id/entries",
621+ Op::Coordination(CoordinationOp::RecordSessionEntries),
622+ &[],
623+ ),
595624 route(
596625 "GET",
597626 "/repos/:owner/:name/context",
+24−4
2626 use crate::telemetry::TelemetryOp;
2727 use crate::packages::PackagesOp;
2828 use crate::folios::FoliosOp;
29+use crate::coordination::CoordinationOp;
2930 use crate::protection::ProtectionOp;
3031 use crate::token_policy::TokenOp;
3132 use crate::operations::Op;
135136 Tool {
136137 name: "issue",
137138 title: "Issues",
138− description: "Issues: what should change. Read one before working on it to see the pull requests already made for it. Issues and pull requests share numbers; `comment` works on either.",
139+ description: "Issues: what should change. Before working on one, `work` gives everything about it (definition of done, who is on it, its pull requests, what it waits on); `claim` says you are on it (advisory and expiring, never a lock), `release` or `handoff` when you stop. `update` with blocked_by sets the issues that must land first. Issues and pull requests share numbers; `comment` works on either.",
139140 default_action: None,
140141 actions: &[
141142 a("list", Op::ListIssues, "Issues on a repository, newest first"),
142143 a("get", Op::GetIssue, "One issue with comments and its pull requests"),
143144 a("create", Op::CreateIssue, "Open an issue"),
144− a("update", Op::UpdateIssue, "Change title, body, labels, milestone or assignees"),
145+ a("update", Op::UpdateIssue, "Change title, body, labels, milestone, assignees or blocked_by"),
145146 a("labels", Op::ListIssueLabels, "The labels an issue or pull request carries"),
146147 a("add_labels", Op::AddIssueLabels, "Add labels to an issue or pull request"),
147148 a("set_labels", Op::SetIssueLabels, "Replace the labels of an issue or pull request"),
152153 a("edit_comment", Op::EditComment, "Change a comment's text: your own, or any with the Maintain role"),
153154 a("delete_comment", Op::DeleteComment, "Delete a comment: your own, or any with the Maintain role"),
154155 a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"),
156+ a("work", Op::Coordination(CoordinationOp::GetWork), "Everything to pick it up: done means, claims, sessions, pull requests, blockers, decisions, recent events"),
157+ a("claim", Op::Coordination(CoordinationOp::ClaimIssue), "Say you are working on it; lists who else is, and what it is blocked by"),
158+ a("release", Op::Coordination(CoordinationOp::ReleaseClaim), "Stop working on it"),
159+ a("handoff", Op::Coordination(CoordinationOp::HandOffIssue), "Hand it off, to someone or anyone, with a note on where it stands"),
160+ a("list_claims", Op::Coordination(CoordinationOp::ListClaims), "Claims on a repository's issues: who is working on what"),
155161 ],
156162 },
157163 Tool {
181187 ],
182188 },
183189 Tool {
190+ name: "session",
191+ title: "Sessions",
192+ description: "Your harness session on a repository, so others see who is working on what: `start` once (idempotent on harness_session_id), `update` as a heartbeat or to attach an issue, pull request, branch or head commit, `record` prompts, reasoning and tool calls (idempotent on key), `end` when done. A session goes idle after 15 minutes without a heartbeat and ends after 2 hours.",
193+ default_action: None,
194+ actions: &[
195+ a("start", Op::Coordination(CoordinationOp::StartSession), "Start one, or get the one started with this harness_session_id"),
196+ a("update", Op::Coordination(CoordinationOp::UpdateSession), "Heartbeat; or attach an issue, pull request, branch, head commit or title"),
197+ a("record", Op::Coordination(CoordinationOp::RecordSessionEntries), "Record prompts, reasoning and tool calls; a key makes each idempotent"),
198+ a("end", Op::Coordination(CoordinationOp::EndSession), "End it"),
199+ a("get", Op::Coordination(CoordinationOp::GetSession), "One session and its entries"),
200+ a("list", Op::Coordination(CoordinationOp::ListSessions), "A repository's sessions: active, idle or ended, by issue or pull request"),
201+ ],
202+ },
203+ Tool {
184204 name: "memory",
185205 title: "Memory",
186206 description: "What the project and its workspace remember for the next agent: how to build, conventions, decisions, traps. Recall before you start; remember one short fact at a time, never a secret.",
849869 assert!(tool.action(default).is_some(), "{}", tool.name);
850870 }
851871 }
852− assert!(TOOLS.len() <= 18, "{} tools", TOOLS.len());
872+ assert!(TOOLS.len() <= 19, "{} tools", TOOLS.len());
853873 }
854874
855875 #[test]
914934 assert_eq!(tool["annotations"]["destructiveHint"], false);
915935 }
916936 let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap();
917− assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get", "labels"]));
937+ assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get", "labels", "work", "list_claims"]));
918938 }
919939
920940 #[test]
+1−0
154154 items: [
155155 { label: 'Connect a coding agent', slug: 'guides/bring-your-own-agent' },
156156 { label: 'Claude Code plugin', slug: 'guides/claude-code-plugin' },
157+ { label: 'Sessions, claims and dependencies', slug: 'guides/sessions' },
157158 { label: 'Sessions and why-blame', slug: 'guides/why-blame' },
158159 { label: 'Memory', slug: 'guides/memory' },
159160 ],
+7−3
1515 | Push code | git over HTTPS, with an [access token](/guides/authentication/#access-tokens) |
1616 | Start the agent when something happens | [Webhooks](/guides/webhooks/), or a [workflow](/guides/actions/) on `issues` or `issue_comment` |
1717 | Show people how a change was made | The pull request's [session](/guides/why-blame/#sessions) |
18+| See who is working on what, and not collide | [Sessions, claims and dependencies](/guides/sessions/) |
1819 | Pass what one agent learned to the next | [Memory](/guides/memory/) |
1920 | Script anything else | The [REST API](/reference/api/), which has the same operations as the tools |
2021
7475
7576 ## How an agent works on an issue
7677
77−1. `issue` with `get`, to read the description, including what done means
78− if it says, and to see which pull requests already exist for it.
78+1. `issue` with `work`, to load everything about it in one call: what
79+ done means, who has claimed it, the pull requests already made for it,
80+ and what it is blocked by. Then `issue` with `claim`, which also says
81+ who else is on it.
7982 2. `memory` with `recall`, to read what the project remembers: how to
8083 build, conventions and traps.
8184 3. `pull_request` with `create` and the issue's number. This opens a draft
285288 | --- | --- |
286289 | `search` | Code, repositories, issues, pull requests and people across g1t; a tracker's ticket |
287290 | `repository` | Repositories, their settings, rulesets, branches, labels, milestones and releases |
288−| `issue` | Issues, labels and comments |
291+| `issue` | Issues, labels and comments; an issue's whole context, and claims |
289292 | `pull_request` | Pull requests: create, record and read sessions, review, merge, the merge queue |
293+| `session` | Your harness's session: start, heartbeat, record, end; who else is working |
290294 | `memory` | `recall` and `remember` |
291295 | `workflow` | Workflow runs and logs, checks, deployments, environments and runners |
292296 | `observability` | A project's requests, logs, traces, errors and metrics in production |
+210−0
1+---
2+title: Sessions, claims and dependencies
3+description: How agents and people on one repository see who is working on what, with sessions of their own harnesses, advisory claims on issues, what an issue is blocked by, and the one call that loads an issue's whole context.
4+---
5+
6+When several agents and people work on one repository at once, each needs
7+to know what the others are doing. g1t keeps that around the issue, which
8+is the unit of work: its description says what is wanted and what done
9+means, its pull requests are the proposals, and around it g1t records:
10+
11+- **Sessions**: one run of your own harness (a Claude Code session, a
12+ Codex run) on the repository, attached to the issue and pull request it
13+ works on.
14+- **Claims**: an advisory, expiring "I'm working on #12". Never a lock.
15+- **Dependencies**: the issues that have to land before this one.
16+
17+Nothing here waits on a harness. A harness that crashes, loses its
18+network or sends the same thing twice does no harm: sessions and claims
19+that go quiet are read as idle, ended or expired the next time anyone
20+looks, and starting a session or recording an entry again is a no-op.
21+What git and g1t record (commits, pull requests, checks, merges) is the
22+truth; sessions are the account of how it got there.
23+
24+## Picking up an issue
25+
26+Before working on an issue, load its whole context in one call: `issue`
27+with `"action": "work"` over MCP, or
28+[`GET /repos/{owner}/{name}/issues/{number}/work`](/reference/api/claims-and-work/get-work/):
29+
30+```sh
31+curl https://api.g1t.sh/repos/acme/web/issues/12/work \
32+ -H "Authorization: Bearer $G1T_TOKEN"
33+```
34+
35+| Field | What it holds |
36+| --- | --- |
37+| `issue` | The issue, with its body. |
38+| `definition_of_done` | The items under its **Definition of done** heading. |
39+| `required_checks` | The checks every pull request into the default branch must pass. |
40+| `claims` | Who says they are working on it now. |
41+| `sessions` | Sessions on it or its pull requests, most recently heard from first. |
42+| `pulls` | Its pull requests. Those still open or draft carry their `required_checks` as they stand on the head, `mergeable`, the other pull requests they `overlap`, and their place in the merge `queue`. |
43+| `blocked_by` | Each issue it waits on, with that issue's claims and pull requests. |
44+| `blocks` | The issues waiting on it. |
45+| `decisions` | [Decision records](/guides/why-blame/#decision-records) on it and its pull requests, newest first. |
46+| `last_integration_failure` | The latest `integration_failure` decision among them, if any: the combination that failed in the merge queue, and the check. |
47+| `recent` | Its latest 20 coordination events, newest first. |
48+
49+Then claim it, and start (or attach) your session.
50+
51+## Sessions
52+
53+A session is one run of your harness on a repository. Start one when the
54+harness starts, keep it alive while it works, and end it when it stops.
55+
56+1. **Start** it with `session` `start`, or
57+ [`POST /repos/{owner}/{name}/sessions`](/reference/api/sessions/start-session/),
58+ naming the `harness` (`claude_code`, `codex`, `cursor`, `copilot` or
59+ `other`) and the harness's own id for the session as
60+ `harness_session_id`. Starting again with the same id returns the
61+ session already started, so a hook can call it every time without
62+ checking first. Give `issue`, `pull` and `branch` when you know them.
63+2. **Heartbeat** with `session` `update`, or
64+ [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/)
65+ with an empty body. The same call attaches the session to an issue or
66+ pull request, or records the `head_commit` it pushed.
67+3. **Record** what happens with `session` `record`, or
68+ [`POST /repos/{owner}/{name}/sessions/{id}/entries`](/reference/api/sessions/record-session-entries/):
69+ prompts, reasoning, tool calls and notes, up to 200 a request. Give each
70+ entry a `key`, and recording it again does nothing.
71+4. **End** it with `session` `end`, or
72+ [`POST /repos/{owner}/{name}/sessions/{id}/end`](/reference/api/sessions/end-session/).
73+
74+```sh
75+curl -X POST https://api.g1t.sh/repos/acme/web/sessions \
76+ -H "Authorization: Bearer $G1T_TOKEN" \
77+ -H "Content-Type: application/json" \
78+ -d '{"harness": "claude_code", "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13", "issue": 12}'
79+```
80+
81+A session's `state` is worked out from when it was last heard from. Every
82+update and every batch of entries counts as a heartbeat:
83+
84+| State | Means |
85+| --- | --- |
86+| `active` | Heard from in the last 15 minutes. |
87+| `idle` | Not heard from for 15 minutes. It may still come back. |
88+| `ended` | Ended with `end_session`, or not heard from for 2 hours (`end_reason` `timed_out`). A heartbeat brings a timed-out session back; an ended one stays ended. |
89+
90+Entries of a session attached to a pull request show in that pull
91+request's [session](/guides/why-blame/#sessions), beside what was recorded
92+on it with `record_session`, so reviewers and why-blame see them. Entries
93+recorded before the session named its pull request move onto it when it
94+does. A session never records tool output unless you send it as a
95+`tool_result` entry; a session is as visible as the repository, so keep
96+secrets out of it.
97+
98+List a repository's sessions with `session` `list`, or
99+[`GET /repos/{owner}/{name}/sessions`](/reference/api/sessions/list-sessions/)
100+by `state`, `issue` or `pull`, and read one with its entries with
101+`session` `get`.
102+
103+## Claims
104+
105+A claim says you are working on an issue. It is advisory: anyone may claim
106+an issue someone else has, and is told about them, so two agents can
107+decide between themselves rather than collide. Nothing is refused because
108+of a claim.
109+
110+- **Claim** an open issue with `issue` `claim`, or
111+ [`POST /repos/{owner}/{name}/issues/{number}/claim`](/reference/api/claims-and-work/claim-issue/).
112+ The answer has your `claim`, the `others` already on the issue, and
113+ `blocked_by`: each issue this one waits on, with its claims and pull
114+ requests.
115+- **Renew** it by claiming again, or give the `session_id` of your session
116+ when you claim, and that session's heartbeats renew it.
117+- **Release** it with `issue` `release`, or
118+ [`POST /repos/{owner}/{name}/issues/{number}/release`](/reference/api/claims-and-work/release-claim/),
119+ with an optional `reason`.
120+- **Hand it off** with `issue` `handoff`, or
121+ [`POST /repos/{owner}/{name}/issues/{number}/handoff`](/reference/api/claims-and-work/hand-off-issue/),
122+ with a `note` on where the work stands, for `to` (a username) or anyone.
123+ When whoever it was for claims the issue, their `work.claimed` event
124+ names the hand-off in `took_over`.
125+
126+```sh
127+curl -X POST https://api.g1t.sh/repos/acme/web/issues/12/claim \
128+ -H "Authorization: Bearer $G1T_TOKEN" \
129+ -H "Content-Type: application/json" \
130+ -d '{"session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", "note": "Taking the argument parsing first."}'
131+```
132+
133+| | |
134+| --- | --- |
135+| How long a claim lasts | `ttl_minutes`: 30 unless you say, at most 240. |
136+| When it ends | Released, handed off, its issue closed, or not renewed in time (`expired`). |
137+| States | `active`, `released`, `expired`, `handed_off`. |
138+
139+See who is on what with `issue` `list_claims`, or
140+[`GET /repos/{owner}/{name}/claims`](/reference/api/claims-and-work/list-claims/):
141+active claims unless `state` says otherwise, and `issue` for one issue's.
142+
143+## Dependencies
144+
145+An issue's `blocked_by` lists the issues of the same repository that have
146+to land before it. Set it with `issue` `update`, or
147+[`PATCH /repos/{owner}/{name}/issues/{number}`](/reference/api/issues/update-issue/),
148+which replaces the whole list:
149+
150+```sh
151+curl -X PATCH https://api.g1t.sh/repos/acme/web/issues/12 \
152+ -H "Authorization: Bearer $G1T_TOKEN" \
153+ -H "Content-Type: application/json" \
154+ -d '{"blocked_by": [9, 10]}'
155+```
156+
157+A list is refused with `422` when a number is not an issue of the
158+repository, names the issue itself, or would make a cycle (the answer
159+shows the cycle, such as `#12 → #9 → #12`). Up to 50 issues. Changing it
160+notes the change in the issue's conversation and needs what editing the
161+issue does: being its author, or the Triage role.
162+
163+g1t says when a dependency moves, as a `dependency.changed` event on the
164+blocked issue: when its list changes, and when one of its blockers closes
165+or has a pull request merged into the default branch. `remaining` lists
166+the blockers still open; when it is empty, the issue is unblocked.
167+
168+## Overlaps
169+
170+Opening a pull request, and each push to it, works out which files it
171+changes. When it starts changing files another open or draft pull request
172+changes, g1t sends `overlap.detected` once for each of the two, naming the
173+other and the files. A pair is announced once, and again only after it stopped
174+overlapping. `get_work` and `pull_request` `get` show current overlaps
175+whenever you look.
176+
177+## Events
178+
179+Coordination is published as events, which [webhooks](/guides/webhooks/)
180+deliver and the repository's timeline lists. Each carries `number` (the
181+issue or pull request it is about) and `issue` where there is one.
182+
183+| Event | When |
184+| --- | --- |
185+| `session.started` | A session starts, or one that ended starts again. Not on a repeated start. |
186+| `session.ended` | A session ends, or times out after 2 hours without a heartbeat. |
187+| `work.claimed` | Someone claims an issue. `others` lists the claims already on it; `took_over` names a hand-off it takes. |
188+| `work.released` | A claim is released, or its issue closed (`reason` `issue_closed`). |
189+| `work.claim_expired` | A claim was not renewed in time. |
190+| `work.handoff_requested` | Someone hands an issue off: `from`, `to` (absent for anyone) and `note`. |
191+| `dependency.changed` | An issue's `blocked_by` changed (`added`, `removed`), or a blocker closed or had its pull request merged (`cleared`). `remaining` lists the blockers still open. |
192+| `overlap.detected` | A pull request started changing files another open one changes: `pull`, `other_pull`, `files`, `issue` and `other_issue`. |
193+
194+A session or claim that went quiet is announced as ended or expired when
195+the repository's sessions or claims are next read or changed, not at the
196+minute it lapsed.
197+
198+## Who may do what
199+
200+| To | You need |
201+| --- | --- |
202+| Read sessions, claims and an issue's context | To be able to read the repository. Public repositories need no token. |
203+| Start a session, claim an issue | A confirmed email address and a role that may open issues; the repository must not be archived. |
204+| Update, record in or end a session | To be the person whose token started it. |
205+| Release a claim or hand it off | To be the person who made it. |
206+| Set `blocked_by` | To be the issue's author, or the Triage role. |
207+
208+With an access token, sessions need `pull_requests:read` or
209+`pull_requests:write`, and claims and `get_work` need `issues:read` or
210+`issues:write`, as [scopes](/guides/authentication/#scopes) say.
+5−1
114114 | `integration.failed` | A pull request failed in the [merge queue](/guides/merge-queue/#when-an-entry-fails) combined with the ones ahead of it. `pull` (also `number`) is the one that answers for it, `issue` its issue, `combination` the pull requests tested together, `check` the check that failed (`name`, `conclusion`, `url`), `suspects` the pull requests ahead it fails with, `bisected` whether smaller combinations were built to find them, and `other_pulls` the rest of the combination. |
115115 | `integration.passed` | A pull request's combined state passed in the merge queue: `pull` and `combination`. |
116116 | `decision.recorded` | A [decision record](/guides/why-blame/#decision-records) was kept on an issue or pull request: `decision`, with `number` and `issue`. |
117−| `session.appended` | A pull request's [session](/guides/why-blame/#sessions) grew: a coding agent recorded entries with `record_session`, or g1t noted a merge queue failure. Busy: choose it only if you need it. |
117+| `session.appended` | A pull request's [session](/guides/why-blame/#sessions) grew: a coding agent recorded entries with `record_session`, a session on it recorded some, or g1t noted a merge queue failure. Busy: choose it only if you need it. |
118+| `session.started`, `session.ended` | A [session](/guides/sessions/#sessions) of someone's harness started (not on a repeated start), or ended or timed out. `data.session`, and `data.number` (its pull request, else its issue) and `data.issue`. |
119+| `work.claimed`, `work.released`, `work.claim_expired`, `work.handoff_requested` | A [claim](/guides/sessions/#claims) on an issue was made, released, expired or handed off. `data.number` and `data.issue` (the issue), `data.claim`; on `work.claimed`, `data.others` (the other active claims) and `data.took_over`; on `work.released`, `data.reason`; on a hand-off, `data.from`, `data.to` and `data.note`. |
120+| `dependency.changed` | What an issue is [blocked by](/guides/sessions/#dependencies) changed, or a blocker closed or had its pull request merged. `data.number` (the blocked issue), `data.blocked_by`, `data.added`, `data.removed`, `data.cleared`, `data.remaining` (blockers still open) and `data.merged_pull`. |
121+| `overlap.detected` | A push made a pull request [change files another open one changes](/guides/sessions/#overlaps). Sent once for each of the two: `data.pull`, `data.other_pull`, `data.files`, `data.issue` and `data.other_issue`. |
118122
119123 [Managing a repository](/guides/managing-repositories/) says what each of
120124 the `repo.` and `branch.` changes does.
+3−2
99
1010 ## Sessions
1111
12−A session belongs to a pull request. It records how the change was made,
13−entry by entry, as it happens:
12+A pull request's session records how the change was made, entry by entry,
13+as it happens. Entries come from `record_session` on the pull request, and
14+from any [harness session](/guides/sessions/#sessions) attached to it:
1415
1516 | Kind | What it holds |
1617 | --- | --- |
+7−7
741741 g1t status [OPTIONS]
742742 ```
743743
744−Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/), [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_claims`](/reference/api/issues/list-claims/).
744+Calls [`search`](/reference/api/search/search/), [`list_notifications`](/reference/api/notifications/list-notifications/), [`get_pull_request`](/reference/api/pull-requests/get-pull-request/), [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_claims`](/reference/api/claims-and-work/list-claims/).
745745
746746 ## g1t session
747747
876876 | --- | --- |
877877 | `<NUMBER>` | The issue's number. |
878878
879−Calls [`get_work`](/reference/api/issues/get-work/).
879+Calls [`get_work`](/reference/api/claims-and-work/get-work/).
880880
881881 ### g1t work claim
882882
892892 | `-n, --note <NOTE>` | What you are doing about it. |
893893 | `--ttl <DURATION>` | How long the claim lasts unless renewed: 30m, 2h (at most 4h). |
894894
895−Calls [`claim_issue`](/reference/api/issues/claim-issue/).
895+Calls [`claim_issue`](/reference/api/claims-and-work/claim-issue/).
896896
897897 ### g1t work release
898898
907907 | `<NUMBER>` | |
908908 | `-r, --reason <REASON>` | Why: done, blocked, abandoned... |
909909
910−Calls [`release_claim`](/reference/api/issues/release-claim/).
910+Calls [`release_claim`](/reference/api/claims-and-work/release-claim/).
911911
912912 ### g1t work handoff
913913
923923 | `--to <LOGIN>` | Who takes it over; anyone when left out. |
924924 | `-n, --note <NOTE>` | Where it stands and what is left, for whoever takes it. |
925925
926−Calls [`hand_off_issue`](/reference/api/issues/hand-off-issue/).
926+Calls [`hand_off_issue`](/reference/api/claims-and-work/hand-off-issue/).
927927
928928 ### g1t work list
929929
933933 g1t work list [OPTIONS]
934934 ```
935935
936−Calls [`list_claims`](/reference/api/issues/list-claims/), [`list_sessions`](/reference/api/sessions/list-sessions/).
936+Calls [`list_claims`](/reference/api/claims-and-work/list-claims/), [`list_sessions`](/reference/api/sessions/list-sessions/).
937937
938938 ### g1t work block
939939
10041004 | `<TARGET>` | A file, or a line of it as PATH:LINE. |
10051005 | `--ref <REF>` | The branch, tag or commit to read it at; the default branch when left out. |
10061006
1007−Calls [`why`](/reference/api/repositories/why/).
1007+Calls [`why`](/reference/api/decisions-and-why/why/).
10081008
10091009 ## g1t api
10101010
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…