Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge main into stream A: coordination meets the merge queue's decisions and the CLI. get_work gives the issue's decision records newest first and its latest integration_failure decision as last_integration_failure, a merge queue failure noted in a pull request's session is noted in the live harness sessions on it or its issue too, the CLI's coordination commands use the generated operations now that the API describes them (its pending list is empty) and show get_work's definition of done, and the session, decision and coordination events sit together in the catalog, the webhook form and the docs.

syntaqxcommitted Parents2f73c7af82977fBrowse files
77 files+1281−3061 files not counted0/77 viewed
+14−0
1+{
2+ "name": "g1t",
3+ "owner": { "name": "Flagon, Inc.", "url": "https://g1t.sh" },
4+ "description": "g1t's plugins for Claude Code.",
5+ "plugins": [
6+ {
7+ "name": "g1t",
8+ "source": "./plugins/claude-code",
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.1.0",
11+ "category": "development"
12+ }
13+ ]
14+}
+2−0
9595 run: npm run test:ops
9696 - name: The manifest matches every wrangler.jsonc
9797 run: node scripts/deploy.mjs manifest --check
98+ - name: The Claude Code plugin's tests
99+ run: node --test plugins/claude-code/test/hooks.test.mjs
98100
99101 build:
100102 name: Build
+231−0
1+//! `list_events`: a repository's timeline, and following it.
2+//!
3+//! Without `after` it is the timeline, newest first, paged back with
4+//! `before`. With `after` (an event id) it is what the log wrote after that
5+//! event, oldest first, and the last event's id is the cursor for the next
6+//! call. With `after` and `wait`, a call that finds nothing holds on until
7+//! something matching is written or `wait` seconds pass, asking the events
8+//! service about once a second, so an agent with no public address can
9+//! follow a repository with one request at a time instead of a webhook.
10+//!
11+//! Holding a request costs a Worker wall time, not CPU time: between polls
12+//! it awaits a timer, which is not billed or limited as CPU. Each poll is
13+//! one subrequest, so a call makes at most `MAX_WAIT_SECONDS` + 1 of them,
14+//! well under the per-request limit, and the hold stays far below the time
15+//! clients and proxies give an idle response.
16+
17+use g1t_contracts::events::{Event, ListArgs};
18+use serde_json::Value;
19+use worker::{Fetcher, Result};
20+
21+/// The longest a call holds on, in seconds.
22+pub const MAX_WAIT_SECONDS: u64 = 25;
23+/// How often a held call asks again.
24+const POLL_MS: u64 = 1_000;
25+/// The most events one call returns; the events service's own page.
26+const MAX_LIMIT: u32 = 200;
27+
28+/// What a `list_events` call asks for, read from its input.
29+#[derive(Debug, Default, PartialEq)]
30+pub struct Asked {
31+ pub after: Option<String>,
32+ pub before: Option<String>,
33+ pub types: Vec<String>,
34+ pub numbers: Vec<u32>,
35+ pub since: Option<String>,
36+ pub limit: Option<u32>,
37+ /// Milliseconds to hold on; zero without `after`.
38+ pub wait_ms: u64,
39+}
40+
41+/// A list given as an array or as one comma-separated string.
42+fn list(value: &Value) -> Vec<String> {
43+ let items: Vec<String> = match value {
44+ Value::String(text) => text.split(',').map(str::to_owned).collect(),
45+ Value::Array(items) => items
46+ .iter()
47+ .map(|item| match item {
48+ Value::String(text) => text.clone(),
49+ other => other.to_string(),
50+ })
51+ .collect(),
52+ Value::Number(number) => vec![number.to_string()],
53+ _ => Vec::new(),
54+ };
55+ items
56+ .into_iter()
57+ .map(|item| item.trim().trim_start_matches('#').to_owned())
58+ .filter(|item| !item.is_empty())
59+ .collect()
60+}
61+
62+fn text(input: &Value, key: &str) -> Option<String> {
63+ input[key]
64+ .as_str()
65+ .map(str::trim)
66+ .filter(|value| !value.is_empty())
67+ .map(str::to_owned)
68+}
69+
70+fn whole(value: &Value) -> Option<u64> {
71+ match value {
72+ Value::Number(number) => number.as_u64().or_else(|| number.as_f64().filter(|n| *n >= 0.0).map(|n| n as u64)),
73+ Value::String(digits) => digits.trim().parse().ok(),
74+ _ => None,
75+ }
76+}
77+
78+/// Reads a call's input, or says what is wrong with it.
79+pub fn asked(input: &Value) -> std::result::Result<Asked, String> {
80+ let mut numbers = Vec::new();
81+ for item in list(&input["numbers"]) {
82+ match item.parse::<u32>() {
83+ Ok(number) if number > 0 => numbers.push(number),
84+ _ => return Err(format!("numbers are issue and pull request numbers, and {item} is not one.")),
85+ }
86+ }
87+ numbers.sort_unstable();
88+ numbers.dedup();
89+ let mut types = list(&input["types"]);
90+ types.sort();
91+ types.dedup();
92+ let limit = match &input["limit"] {
93+ Value::Null => None,
94+ value => match whole(value) {
95+ Some(limit) if limit > 0 => Some(u32::try_from(limit).unwrap_or(MAX_LIMIT).min(MAX_LIMIT)),
96+ _ => return Err("limit is a whole number from 1 to 200.".to_owned()),
97+ },
98+ };
99+ let after = text(input, "after");
100+ let wait = match &input["wait"] {
101+ Value::Null => 0,
102+ value => match whole(value) {
103+ Some(seconds) => seconds.min(MAX_WAIT_SECONDS),
104+ None => return Err(format!("wait is a number of seconds, at most {MAX_WAIT_SECONDS}.")),
105+ },
106+ };
107+ let before = text(input, "before");
108+ if after.is_some() && before.is_some() {
109+ return Err("Give after to follow the timeline or before to page back through it, not both.".to_owned());
110+ }
111+ Ok(Asked {
112+ wait_ms: if after.is_some() { wait * 1_000 } else { 0 },
113+ after,
114+ before,
115+ types,
116+ numbers,
117+ since: text(input, "since"),
118+ limit,
119+ })
120+}
121+
122+impl Asked {
123+ fn args(&self, repo_id: &str) -> ListArgs {
124+ ListArgs {
125+ repo_id: Some(repo_id.to_owned()),
126+ types: self.types.clone(),
127+ before: self.before.clone(),
128+ after: self.after.clone(),
129+ numbers: self.numbers.clone(),
130+ since: self.since.clone(),
131+ limit: self.limit,
132+ ..ListArgs::default()
133+ }
134+ }
135+}
136+
137+/// How long to sleep before asking again, at `now`, for a call that holds
138+/// on until `deadline`: `None` when it is time to answer with nothing.
139+/// The last poll falls on the deadline itself.
140+pub fn next_poll(now: u64, deadline: u64) -> Option<u64> {
141+ if now >= deadline {
142+ None
143+ } else {
144+ Some(POLL_MS.min(deadline - now))
145+ }
146+}
147+
148+/// The events of the repository `repo_id` as asked, holding on for them
149+/// when asked to.
150+pub async fn list_events(events: &Fetcher, repo_id: &str, asked: &Asked) -> Result<Vec<Event>> {
151+ let args = asked.args(repo_id);
152+ let deadline = g1t_kit::now_ms() + asked.wait_ms;
153+ loop {
154+ let found: Vec<Event> = g1t_kit::call(events, "list", &args).await?;
155+ if !found.is_empty() {
156+ return Ok(found);
157+ }
158+ match next_poll(g1t_kit::now_ms(), deadline) {
159+ Some(ms) => worker::Delay::from(std::time::Duration::from_millis(ms)).await,
160+ None => return Ok(found),
161+ }
162+ }
163+}
164+
165+#[cfg(test)]
166+mod tests {
167+ use super::*;
168+ use serde_json::json;
169+
170+ #[test]
171+ fn lists_come_as_commas_or_arrays() {
172+ let a = asked(&json!({
173+ "types": "work.claimed, integration.failed,,work.claimed",
174+ "numbers": "12,#7, 12",
175+ }))
176+ .unwrap();
177+ assert_eq!(a.types, vec!["integration.failed", "work.claimed"]);
178+ assert_eq!(a.numbers, vec![7, 12]);
179+ let b = asked(&json!({ "types": ["push"], "numbers": [3, "4"] })).unwrap();
180+ assert_eq!(b.types, vec!["push"]);
181+ assert_eq!(b.numbers, vec![3, 4]);
182+ assert!(asked(&json!({ "numbers": "seven" })).is_err());
183+ assert!(asked(&json!({ "numbers": [0] })).is_err());
184+ }
185+
186+ #[test]
187+ fn wait_holds_only_a_follow_and_never_past_25_seconds() {
188+ assert_eq!(asked(&json!({ "wait": 10 })).unwrap().wait_ms, 0, "nothing to wait for without after");
189+ assert_eq!(asked(&json!({ "after": "evt_1", "wait": "10" })).unwrap().wait_ms, 10_000);
190+ assert_eq!(asked(&json!({ "after": "evt_1", "wait": 600 })).unwrap().wait_ms, 25_000);
191+ assert_eq!(asked(&json!({ "after": "evt_1" })).unwrap().wait_ms, 0);
192+ assert!(asked(&json!({ "after": "evt_1", "wait": "soon" })).is_err());
193+ assert!(asked(&json!({ "after": "evt_1", "wait": -1 })).is_err());
194+ }
195+
196+ #[test]
197+ fn limits_are_kept_to_a_page() {
198+ assert_eq!(asked(&json!({ "limit": "500" })).unwrap().limit, Some(200));
199+ assert_eq!(asked(&json!({ "limit": 20 })).unwrap().limit, Some(20));
200+ assert_eq!(asked(&json!({})).unwrap().limit, None);
201+ assert!(asked(&json!({ "limit": 0 })).is_err());
202+ }
203+
204+ #[test]
205+ fn after_and_before_do_not_mix() {
206+ assert!(asked(&json!({ "after": "evt_2", "before": "evt_1" })).is_err());
207+ let a = asked(&json!({ "after": "evt_2", "since": "2026-10-12T00:00:00Z" })).unwrap();
208+ let args = a.args("rep_1");
209+ assert_eq!(args.after.as_deref(), Some("evt_2"));
210+ assert_eq!(args.since.as_deref(), Some("2026-10-12T00:00:00Z"));
211+ assert_eq!(args.repo_id.as_deref(), Some("rep_1"));
212+ }
213+
214+ #[test]
215+ fn polls_come_about_once_a_second_and_the_last_on_the_deadline() {
216+ let start = 1_000_000;
217+ let deadline = start + 25_000;
218+ let mut now = start;
219+ let mut polls = 1; // the first, before any sleep
220+ while let Some(ms) = next_poll(now, deadline) {
221+ assert!(ms > 0 && ms <= 1_000);
222+ now += ms;
223+ polls += 1;
224+ }
225+ assert_eq!(now, deadline);
226+ assert_eq!(polls, 26, "one call makes at most 26 polls");
227+ assert_eq!(next_poll(5, 5), None);
228+ assert_eq!(next_poll(5, 4), None, "a slow poll past the deadline answers");
229+ assert_eq!(next_poll(0, 300), Some(300));
230+ }
231+}
+2−0
2626 mod oidc;
2727 mod packages;
2828 mod folios;
29+mod follow;
2930 mod people;
3031 mod openapi;
3132 mod pins;
4041 mod runners;
4142 mod security;
4243 mod tools;
44+mod why;
4345 mod token_policy;
4446 mod toolkit;
4547
+3−1
1414 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.
1616 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. What a session on a pull request records shows in that pull request's session, so reviewers see why the change is what it is; pull_request record_session appends to it directly and read_session reads it back.
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.
18+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.
19+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.
1820 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.
1921 Security: security code_alerts, vulnerability_alerts and secret_alerts show what to fix; fix it in your pull request, which the Code scanning and Dependency review checks judge.
2022 Production: observability overview and errors show what a project's running software did; follow a request or trace to its logs.";
+11−0
398398 ],
399399 ),
400400 (
401+ "Decisions and why",
402+ "Decision records: why the work is the way it is, and how sure that is (claimed, tested by a check or the merge queue, or approved by a person). And why a line of code is the way it is: its commit, pull request, issue, decisions and session.",
403+ &[Op::ListDecisions, Op::RecordDecision, Op::Why],
404+ ),
405+ (
401406 "Memory",
402407 "What agents and people learned that the next agent should know, for one project or across a workspace. Members only; never a secret.",
403408 &[Op::Remember, Op::Recall],
678683 Op::CreatePullRequest => "Create a pull request",
679684 Op::RecordSession => "Record session entries",
680685 Op::ReadSession => "Read a session",
686+ Op::RecordDecision => "Record a decision",
687+ Op::ListDecisions => "List a pull request's decisions",
688+ Op::Why => "Explain a line",
681689 Op::MarkPullRequestReady => "Mark a pull request ready",
682690 Op::ClosePullRequest => "Close a pull request",
683691 Op::ReopenPullRequest => "Reopen a pull request",
893901 ("DELETE", ":label") if route.path.contains("/issues/:number/") => "remove_issue_label".to_owned(),
894902 ("DELETE", "saved") => "unsave_thread".to_owned(),
895903 ("DELETE", "snooze") => "unsnooze_thread".to_owned(),
904+ // An issue's decisions, by its number.
905+ ("GET", "decisions") if route.path.contains("/issues/:number/") => "list_issue_decisions".to_owned(),
896906 _ => op.name().to_owned(),
897907 };
898908 if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
921931 "delete_issue_subscription" => "Unsubscribe from an issue",
922932 "unsave_thread" => "Unsave a thread",
923933 "unsnooze_thread" => "Bring a snoozed thread back",
934+ "list_issue_decisions" => "List an issue's decisions",
924935 _ => title(route.op),
925936 };
926937 if id.ends_with("_for_workspace") {
+111−14
1111 };
1212 use g1t_contracts::codeowners::CodeOwnersErrorsArgs;
1313 use g1t_contracts::identity::AgentScope;
14−use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
14+use g1t_contracts::events::Event;
1515 use g1t_contracts::identity::{CreateWorkspaceArgs, UpdateWorkspaceArgs, Workspace};
1616 use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath};
1717 use g1t_contracts::teams::{
185185 UpdatePullRequest,
186186 RecordSession,
187187 ReadSession,
188+ RecordDecision,
189+ ListDecisions,
190+ Why,
188191 MarkPullRequestReady,
189192 ConvertPullRequestToDraft,
190193 ClosePullRequest,
690693 }
691694
692695 impl Op {
693− pub const ALL: [Op; 364] = [
696+ pub const ALL: [Op; 367] = [
694697 Op::Whoami,
695698 Op::GetWorkspace,
696699 Op::CreateWorkspace,
765768 Op::UpdatePullRequest,
766769 Op::RecordSession,
767770 Op::ReadSession,
771+ Op::RecordDecision,
772+ Op::ListDecisions,
773+ Op::Why,
768774 Op::MarkPullRequestReady,
769775 Op::ConvertPullRequestToDraft,
770776 Op::ClosePullRequest,
11381144 Op::UpdatePullRequest => "update_pull_request",
11391145 Op::RecordSession => "record_session",
11401146 Op::ReadSession => "read_session",
1147+ Op::RecordDecision => "record_decision",
1148+ Op::ListDecisions => "list_decisions",
1149+ Op::Why => "why",
11411150 Op::MarkPullRequestReady => "mark_pull_request_ready",
11421151 Op::ClosePullRequest => "close_pull_request",
11431152 Op::ReopenPullRequest => "reopen_pull_request",
14691478 "Append entries to a pull request's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end."
14701479 }
14711480 Op::ReadSession => "The recorded session of a pull request, oldest entry first.",
1481+ Op::RecordDecision => {
1482+ "Record a decision on a pull request: why the change is made the way it is (proposal), something you say is true of it (claim), what you changed to fix a failure (fix), or a note. It is kept with confidence claimed, attributed to you, with the issue the pull request is for, and published as decision.recorded. evidence is an object of what it rests on, such as {\"commit\": \"…\", \"check\": \"CI\"}; session_id names the session it was made in. g1t records the decisions it saw proved itself: integration_failure and integration_passed when the merge queue combines pull requests (confidence tested), approval when a person approves (approved), and merge."
1483+ }
1484+ Op::ListDecisions => {
1485+ "The decision records on an issue (with those on its pull requests) or on a pull request, oldest first: each with its kind (proposal, claim, integration_failure, integration_passed, fix, approval, merge or note), its confidence (claimed: someone said so; tested: a check or a combination of pull requests proved it; approved: a person approved it), who recorded it (absent for g1t), its summary and its evidence. Issues and pull requests share numbers, so number names either."
1486+ }
1487+ Op::Why => {
1488+ "Why a line of a file is the way it is: the commit that last changed it (by blame, at ref or the default branch), the merged pull request it arrived in, that pull request's issue, the decision records on the pull request, and the session entries recorded around that commit (what was said and the tool calls that touched the file, at most eight). pull, issue, decisions and sessions are empty when the commit did not arrive through a pull request."
1489+ }
14721490 Op::MarkPullRequestReady => {
14731491 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why."
14741492 }
14821500 "Land a pull request on its base, the branch it merges into (the default branch unless it names another). Merging needs the Write role or higher, and only once it is marked ready and it meets every rule that holds for its base (see rules and required_checks on get_pull_request: approvals, checks, deployments, merge windows and the rest, from the repository's and its workspace's rulesets); the refusal names the first rule not met. With ignore_checks, someone who may merge can bypass required checks where the rule allows it; with bypass_rules, someone a ruleset lists as a bypass actor merges past its rules, and it is recorded. Merging into the default branch resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded; merging into another branch leaves the issue open. Where the repository has a merge queue, a pull request into the default branch joins the queue instead of landing at once. If its base has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests into its default branch to be up to date refuses instead, so pull the base into its fork or branch, push, and merge again. Check status in the result to see whether it has landed."
14831501 }
14841502 Op::ListEvents => {
1485− "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first."
1503+ "The timeline of a repository: pushes, issues, pull requests, comments, sessions, claims and integration results, newest first; page back with before. To follow it, give after (an event id): the events written after that one come oldest first, and the last one's id is the next call's after. With after and wait (seconds, at most 25), a call that finds nothing holds on until a matching event is written or the wait ends, then returns what it has, perhaps nothing. Narrow it with types, numbers (issues and pull requests, matching events about them and comments on them), since and limit (50 by default, at most 200)."
14861504 }
14871505 Op::ListIntegrations => {
14881506 "A workspace's integrations: its AI providers, the alert sources that open issues (Sentry, Datadog, webhooks), and the trackers whose tickets agents can read (Jira, Linear). Secrets are never returned. Members only."
24302448 })),
24312449 &["repo", "number"],
24322450 ),
2451+ Op::RecordDecision => object(
2452+ numbered(json!({
2453+ "kind": {
2454+ "type": "string",
2455+ "enum": ["proposal", "claim", "fix", "note"],
2456+ "description": "What it is: why the change is made this way (proposal), something true of it (claim), what fixed a failure (fix), or a note.",
2457+ },
2458+ "summary": { "type": "string", "description": "The decision, in a sentence or two. At most 2,000 characters." },
2459+ "evidence": {
2460+ "type": "object",
2461+ "description": "What it rests on, such as a commit, a check's name or the pull requests involved. At most 8 KB of JSON.",
2462+ },
2463+ "session_id": { "type": "string", "description": "The session it was made in, if you have one." },
2464+ })),
2465+ &["repo", "number", "kind", "summary"],
2466+ ),
2467+ Op::ListDecisions => object(numbered(json!({})), &["repo", "number"]),
2468+ Op::Why => object(
2469+ json!({
2470+ "repo": repo_schema(),
2471+ "path": { "type": "string", "description": "The file, from the repository's root, such as \"src/main.rs\"." },
2472+ "line": { "type": "integer", "description": "The line, counted from 1." },
2473+ "ref": { "type": "string", "description": "A branch, tag or commit. The default branch if not given." },
2474+ }),
2475+ &["repo", "path", "line"],
2476+ ),
24332477 Op::MarkPullRequestReady => object(
24342478 numbered(json!({ "summary": { "type": "string", "description": "Markdown." } })),
24352479 &["repo", "number", "summary"],
24542498 Op::ListEvents => object(
24552499 json!({
24562500 "repo": repo_schema(),
2457− "before": { "type": "string", "description": "Event id to page back from." },
2501+ "before": { "type": "string", "description": "Event id to page back from: older events, newest first." },
2502+ "after": { "type": "string", "description": "Event id to follow from: the events written after it, oldest first. Not with before." },
2503+ "types": {
2504+ "type": "array",
2505+ "items": { "type": "string" },
2506+ "description": "Only these event types (work.claimed, integration.failed, ...). In a query string, a comma-separated list.",
2507+ },
2508+ "numbers": {
2509+ "type": "array",
2510+ "items": { "type": "integer" },
2511+ "description": "Only events about these issues or pull requests, and comments, reviews and links on them. In a query string, a comma-separated list.",
2512+ },
2513+ "since": { "type": "string", "description": "Only events at or after this time (RFC 3339)." },
2514+ "limit": { "type": "integer", "minimum": 1, "maximum": 200, "description": "How many events at most; 50 by default." },
2515+ "wait": {
2516+ "type": "integer",
2517+ "minimum": 0,
2518+ "maximum": 25,
2519+ "description": "With after: seconds to hold on for a matching event when there is none yet. At most 25.",
2520+ },
24582521 }),
24592522 &["repo"],
24602523 ),
31543217 | Op::ListPullRequests
31553218 | Op::GetPullRequest
31563219 | Op::ReadSession
3220+ | Op::ListDecisions
3221+ | Op::Why
31573222 | Op::GetPullRequestChanges
31583223 | Op::ListEvents
31593224 | Op::GetRepoSettings
44584523 .await
44594524 }
44604525 Op::ReadSession => pass(work, "read_session", &view()).await,
4526+ Op::RecordDecision => {
4527+ let Some(kind) = input["kind"].as_str().and_then(g1t_contracts::decisions::DecisionKind::parse) else {
4528+ return failed(FailureCode::Invalid, "kind must be proposal, claim, fix or note.");
4529+ };
4530+ pass(
4531+ work,
4532+ "record_decision",
4533+ &g1t_contracts::decisions::RecordDecisionArgs {
4534+ actor: actor(),
4535+ repo,
4536+ number,
4537+ kind,
4538+ summary: text(input, "summary"),
4539+ evidence: input.get("evidence").cloned(),
4540+ session_id: optional_text(input, "session_id"),
4541+ },
4542+ )
4543+ .await
4544+ }
4545+ Op::ListDecisions => {
4546+ pass(
4547+ work,
4548+ "list_decisions",
4549+ &g1t_contracts::decisions::ListDecisionsArgs { repo, viewer: viewer.clone(), number },
4550+ )
4551+ .await
4552+ }
4553+ Op::Why => {
4554+ let Some(line) = integer(input, "line").filter(|line| *line > 0) else {
4555+ return failed(FailureCode::Invalid, "Give line, counted from 1.");
4556+ };
4557+ let path = text(input, "path");
4558+ if path.trim().is_empty() {
4559+ return failed(FailureCode::Invalid, "Give path: the file, from the repository's root.");
4560+ }
4561+ crate::why::why(services, viewer, repo, path, line, optional_text(input, "ref")).await
4562+ }
44614563 Op::MarkPullRequestReady => pass(work, "ready_pull", &pull_action()).await,
44624564 Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await,
44634565 Op::ReopenPullRequest => pass(work, "reopen_pull", &pull_action()).await,
47824884 .await
47834885 }
47844886 Op::ListEvents => {
4887+ let asked = match crate::follow::asked(input) {
4888+ Ok(asked) => asked,
4889+ Err(message) => return failed(FailureCode::Invalid, &message),
4890+ };
47854891 let found: Outcome<Repo> = call(
47864892 repos,
47874893 "get",
47954901 Outcome::Ok(repo) => repo,
47964902 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
47974903 };
4798− let timeline: Vec<Event> = g1t_kit::call(
4799− events,
4800− "list",
4801− &ListEventsArgs {
4802− repo_id: Some(repo.id),
4803− before: optional_text(input, "before"),
4804− ..ListEventsArgs::default()
4805− },
4806− )
4807− .await?;
4904+ let timeline: Vec<Event> = crate::follow::list_events(events, &repo.id, &asked).await?;
48084905 ok(&timeline)
48094906 }
48104907 // Who has access: identity decides, from the repository as the
+189−2
946946 }
947947 }
948948 ],
949− "notes": "Returns 50 events a page. To page back, send the `id` of the last event you have as `before`. `actor` is a user id, and `data` depends on `type`."
949+ "notes": "Returns 50 events a page, newest first. To page back, send the `id` of the last event you have as `before`. `actor` is a user id, and `data` depends on `type`.\n\nTo follow a repository without a webhook, send `after` instead: the events written after that event come oldest first, and the last one's `id` is your next `after`. Add `wait` (seconds, at most 25) and a call that finds nothing holds on until a matching event is written or the time is up, then returns what it has, which may be an empty list; call again with the same `after`. Events come in the order g1t wrote them, so none is skipped when two arrive close together.\n\n```sh\ncurl -H \"Authorization: Bearer $G1T_TOKEN\" \\\n \"https://api.g1t.sh/repos/acme/rocket/events?after=evt_01m43smh3ve52vkcna207vqctf&types=work.claimed,integration.failed&numbers=12&wait=25\"\n```\n\n`types` and `numbers` are comma-separated lists in a query string, arrays in a body or an MCP call. `numbers` matches events about those issues and pull requests (`data.number`) and comments, reviews and links on them (`data.issue`). `since` is an RFC 3339 time. `after` and `before` cannot be given together."
950950 },
951951 "list_issues": {
952952 "query": {
27442744 "state": "open"
27452745 }
27462746 ],
2747− "decisions": [],
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+ ],
27482763 "last_integration_failure": null,
27492764 "recent": [
27502765 {
28022817 },
28032818 "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)."
28042819 },
2820+ "record_decision": {
2821+ "request": {
2822+ "kind": "fix",
2823+ "summary": "Renamed the config key #12 also introduced, so both read retry_limit.",
2824+ "evidence": {
2825+ "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b"
2826+ },
2827+ "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh"
2828+ },
2829+ "response": {
2830+ "id": "dec_01kpz9a1b3c5d7e9f1g3h5j7kb",
2831+ "issue": 9,
2832+ "pull": 14,
2833+ "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh",
2834+ "actor": "ana",
2835+ "kind": "fix",
2836+ "confidence": "claimed",
2837+ "summary": "Renamed the config key #12 also introduced, so both read retry_limit.",
2838+ "evidence": {
2839+ "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b"
2840+ },
2841+ "created_at": "2026-10-12T15:31:40.019Z"
2842+ },
2843+ "notes": "Kept as `claimed`. g1t records `integration_failure`, `integration_passed`, `approval` and `merge` itself. Publishes `decision.recorded`. See [decision records](/guides/why-blame/#decision-records)."
2844+ },
2845+ "list_decisions": {
2846+ "response": [
2847+ {
2848+ "id": "dec_01kpz8m2q4r6s8t0v2w4x6y8za",
2849+ "issue": 9,
2850+ "pull": 14,
2851+ "session_id": null,
2852+ "actor": null,
2853+ "kind": "integration_failure",
2854+ "confidence": "tested",
2855+ "summary": "#14 failed in the merge queue together with #12: `CI / merge_group` failed when they were combined.",
2856+ "evidence": {
2857+ "commit": "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b",
2858+ "check": {
2859+ "name": "CI / merge_group",
2860+ "conclusion": "failure",
2861+ "url": "https://g1t.sh/acme/web/actions/runs/run_01kpz8k0"
2862+ },
2863+ "combination": [
2864+ 11,
2865+ 12,
2866+ 14
2867+ ],
2868+ "other_pulls": [
2869+ 11,
2870+ 12
2871+ ],
2872+ "suspects": [
2873+ 12
2874+ ],
2875+ "bisected": true
2876+ },
2877+ "created_at": "2026-10-12T15:04:11.204Z"
2878+ },
2879+ {
2880+ "id": "dec_01kpz9a1b3c5d7e9f1g3h5j7kb",
2881+ "issue": 9,
2882+ "pull": 14,
2883+ "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh",
2884+ "actor": "ana",
2885+ "kind": "fix",
2886+ "confidence": "claimed",
2887+ "summary": "Renamed the config key #12 also introduced, so both read retry_limit.",
2888+ "evidence": {
2889+ "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b"
2890+ },
2891+ "created_at": "2026-10-12T15:31:40.019Z"
2892+ }
2893+ ],
2894+ "notes": "`GET /repos/{owner}/{name}/issues/{number}/decisions` lists an issue's, with those on its pull requests."
2895+ },
2896+ "why": {
2897+ "query": {
2898+ "path": "src/config.rs",
2899+ "line": 42
2900+ },
2901+ "response": {
2902+ "path": "src/config.rs",
2903+ "line": 42,
2904+ "commit": {
2905+ "hash": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b",
2906+ "tree_hash": "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0",
2907+ "message": "Read retry_limit, as #12 names it",
2908+ "author": {
2909+ "name": "Ana Lima",
2910+ "email": "ana@example.com"
2911+ },
2912+ "parents": [
2913+ "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b"
2914+ ],
2915+ "authored_at": "2026-10-12T15:29:02Z"
2916+ },
2917+ "pull": {
2918+ "number": 14,
2919+ "title": "Retry failed webhook deliveries",
2920+ "author": "ana",
2921+ "agent": "claude-code",
2922+ "merged_by": "g1t",
2923+ "merged_at": "2026-10-12T15:52:18.330Z"
2924+ },
2925+ "issue": {
2926+ "number": 9,
2927+ "title": "Webhook deliveries are not retried",
2928+ "state": "closed",
2929+ "body": "A delivery that times out is never sent again."
2930+ },
2931+ "decisions": [
2932+ {
2933+ "id": "dec_01kpz8m2q4r6s8t0v2w4x6y8za",
2934+ "issue": 9,
2935+ "pull": 14,
2936+ "session_id": null,
2937+ "actor": null,
2938+ "kind": "integration_failure",
2939+ "confidence": "tested",
2940+ "summary": "#14 failed in the merge queue together with #12: `CI / merge_group` failed when they were combined.",
2941+ "evidence": {
2942+ "commit": "4be1c0d9a2f3e4b5c6d7e8f90a1b2c3d4e5f6a7b",
2943+ "check": {
2944+ "name": "CI / merge_group",
2945+ "conclusion": "failure",
2946+ "url": "https://g1t.sh/acme/web/actions/runs/run_01kpz8k0"
2947+ },
2948+ "combination": [
2949+ 11,
2950+ 12,
2951+ 14
2952+ ],
2953+ "other_pulls": [
2954+ 11,
2955+ 12
2956+ ],
2957+ "suspects": [
2958+ 12
2959+ ],
2960+ "bisected": true
2961+ },
2962+ "created_at": "2026-10-12T15:04:11.204Z"
2963+ },
2964+ {
2965+ "id": "dec_01kpz9a1b3c5d7e9f1g3h5j7kb",
2966+ "issue": 9,
2967+ "pull": 14,
2968+ "session_id": "ses_01kpz7x9y1z3a5b7c9d1e3f5gh",
2969+ "actor": "ana",
2970+ "kind": "fix",
2971+ "confidence": "claimed",
2972+ "summary": "Renamed the config key #12 also introduced, so both read retry_limit.",
2973+ "evidence": {
2974+ "commit": "7d3e9a1c5b2f4e6a8c0d2e4f6a8b0c2d4e6f8a0b"
2975+ },
2976+ "created_at": "2026-10-12T15:31:40.019Z"
2977+ }
2978+ ],
2979+ "sessions": [
2980+ {
2981+ "seq": 31,
2982+ "kind": "message",
2983+ "text": "#12 renamed retries to retry_limit; reading the new name here.",
2984+ "tool": null,
2985+ "commit": null,
2986+ "at": "2026-10-12T15:27:40.118Z"
2987+ }
2988+ ]
2989+ },
2990+ "notes": "`ref` reads the file at a branch, tag or commit. See [sessions and why-blame](/guides/why-blame/#why-over-the-api)."
2991+ },
28052992 "list_workflows": {
28062993 "response": [
28072994 {
+3−0
168168 Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent),
169169 Op::ListCheckNames => through::<Vec<work::SeenCheck>>(op, sent),
170170 Op::GetMergeQueue => through::<work::QueueView>(op, sent),
171+ Op::ListDecisions => through::<Vec<g1t_contracts::decisions::Decision>>(op, sent),
172+ Op::RecordDecision => through::<g1t_contracts::decisions::Decision>(op, sent),
173+ Op::Why => through::<g1t_contracts::decisions::Why>(op, sent),
171174 Op::ListIssues => through::<Vec<work::Issue>>(op, sent),
172175 Op::CreateIssue | Op::UpdateIssue | Op::CloseIssue | Op::ReopenIssue => {
173176 through::<work::Issue>(op, sent)
+36−1
531531 "GET",
532532 "/repos/:owner/:name/events",
533533 Op::ListEvents,
534− &[("before", "before")],
534+ &[
535+ ("before", "before"),
536+ ("after", "after"),
537+ ("types", "types"),
538+ ("numbers", "numbers"),
539+ ("since", "since"),
540+ ("limit", "limit"),
541+ ("wait", "wait"),
542+ ],
535543 ),
536544 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
537545 route("POST", "/repos/:owner/:name/labels", Op::CreateLabel, &[]),
10181026 Op::RecordSession,
10191027 &[],
10201028 ),
1029+ // Decision records, on a pull request or an issue (with its pull
1030+ // requests'), and why a line is the way it is.
1031+ route("GET", "/repos/:owner/:name/pulls/:number/decisions", Op::ListDecisions, &[]),
1032+ route("POST", "/repos/:owner/:name/pulls/:number/decisions", Op::RecordDecision, &[]),
1033+ route("GET", "/repos/:owner/:name/issues/:number/decisions", Op::ListDecisions, &[]),
1034+ route("GET", "/repos/:owner/:name/why", Op::Why, &[("path", "path"), ("line", "line"), ("ref", "ref")]),
10211035 route(
10221036 "POST",
10231037 "/repos/:owner/:name/pulls/:number/ready",
13641378 }
13651379
13661380 #[test]
1381+ fn events_are_followed_with_a_cursor_filters_and_a_wait() {
1382+ let query = [
1383+ ("after".to_owned(), "evt_1".to_owned()),
1384+ ("types".to_owned(), "work.claimed,integration.failed".to_owned()),
1385+ ("numbers".to_owned(), "12,14".to_owned()),
1386+ ("wait".to_owned(), "25".to_owned()),
1387+ ("limit".to_owned(), "20".to_owned()),
1388+ ("since".to_owned(), "2026-10-12T00:00:00Z".to_owned()),
1389+ ];
1390+ let (route, input) = resolve("GET", "/repos/acme/rocket/events", &query, Value::Null).unwrap();
1391+ assert_eq!(route.op, Op::ListEvents);
1392+ let asked = crate::follow::asked(&input).unwrap();
1393+ assert_eq!(asked.after.as_deref(), Some("evt_1"));
1394+ assert_eq!(asked.types, vec!["integration.failed", "work.claimed"]);
1395+ assert_eq!(asked.numbers, vec![12, 14]);
1396+ assert_eq!(asked.wait_ms, 25_000);
1397+ assert_eq!(asked.limit, Some(20));
1398+ assert_eq!(asked.since.as_deref(), Some("2026-10-12T00:00:00Z"));
1399+ }
1400+
1401+ #[test]
13671402 fn notifications_are_addressed_as_threads_and_by_issue() {
13681403 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap();
13691404 assert_eq!(route.op, Op::MarkThreadDone);
+3−0
127127 a("list_deleted", Op::ListDeletedRepos, "A workspace's deleted repositories"),
128128 a("restore", Op::RestoreRepo, "Restore a deleted one"),
129129 a("purge", Op::PurgeRepo, "Remove a deleted one for good"),
130+ a("why", Op::Why, "Why a line is the way it is: commit, pull request, issue, decisions, session"),
130131 a("security_alerts", Op::ListSecurityAlerts, "Secret and dependency alerts, filtered by state"),
131132 a("dismiss_alert", Op::DismissSecurityAlert, "Dismiss an alert with a reason"),
132133 a("reopen_alert", Op::ReopenSecurityAlert, "Reopen a dismissed alert"),
172173 a("update", Op::UpdatePullRequest, "Change its base branch, labels, milestone, assignees or reviewers"),
173174 a("record_session", Op::RecordSession, "Append prompt, reasoning and tool entries to its session"),
174175 a("read_session", Op::ReadSession, "Its recorded session"),
176+ a("record_decision", Op::RecordDecision, "Record why: a proposal, claim, fix or note, kept as claimed"),
177+ a("list_decisions", Op::ListDecisions, "Decision records on a pull request, or an issue and its pull requests"),
175178 a("ready", Op::MarkPullRequestReady, "Mark a draft ready, with a summary"),
176179 a("draft", Op::ConvertPullRequestToDraft, "Turn it back into a draft"),
177180 a("request_reviewers", Op::RequestReviewers, "Ask people or teams to review it"),
+324−0
1+//! `why`: why a line of a file is the way it is.
2+//!
3+//! The line's commit comes from the repos service's blame; the merged pull
4+//! request it arrived in is the one whose head it is, or else the one whose
5+//! stretch of the default branch's history holds it (as the site's blame
6+//! view finds it, `apps/web/app/lib/provenance.server.ts`); then that pull
7+//! request's issue, its decision records, and the session entries recorded
8+//! around the commit.
9+
10+use g1t_contracts::decisions::{Decision, ListDecisionsArgs, Why, WhyIssue, WhyPull};
11+use g1t_contracts::repos::{Blame, BlameArgs, Commit, LogArgs, RepoPath};
12+use g1t_contracts::work::{IssueDetail, ListPullsArgs, Pull, PullStatus, SessionEntry, SessionEntryKind, State, ViewArgs};
13+use g1t_contracts::{FailureCode, Outcome, Viewer};
14+use serde_json::Value;
15+use worker::Result;
16+
17+use crate::operations::Services;
18+
19+/// How far back the default branch is read to place a commit.
20+const HISTORY_READ: u32 = 300;
21+/// How many steps of a session are shown for one commit.
22+const MAX_STEPS: usize = 8;
23+/// Longest a step's text is shown.
24+const MAX_STEP_CHARS: usize = 600;
25+/// Longest an issue's description is shown.
26+const MAX_BODY_CHARS: usize = 900;
27+
28+/// The commit that last changed `line`, from a blame of the file.
29+pub(crate) fn commit_of_line(blame: &Blame, line: u32) -> Option<&Commit> {
30+ let range = blame.ranges.iter().find(|range| range.start <= line && line <= range.end)?;
31+ blame.commits.iter().find(|commit| commit.hash == range.commit)
32+}
33+
34+/// The merged pull request `hash` arrived in: the one whose head it is,
35+/// or else the one whose stretch of `history` (the default branch, newest
36+/// first) holds it, from its head back to the commit it started from.
37+pub(crate) fn pull_for_commit<'a>(merged: &'a [Pull], history: &[String], hash: &str) -> Option<&'a Pull> {
38+ let merged: Vec<&Pull> = merged
39+ .iter()
40+ .filter(|pull| pull.status == PullStatus::Merged && pull.head_commit.is_some())
41+ .collect();
42+ if let Some(direct) = merged.iter().find(|pull| pull.head_commit.as_deref() == Some(hash)) {
43+ return Some(direct);
44+ }
45+ let position = |commit: &str| history.iter().position(|known| known == commit);
46+ let at = position(hash)?;
47+ let mut best: Option<(&Pull, usize)> = None;
48+ for pull in merged {
49+ let Some(head) = pull.head_commit.as_deref().and_then(position) else { continue };
50+ if head > at {
51+ continue;
52+ }
53+ if let Some(base) = pull.merge_base.as_deref().and_then(position)
54+ && base <= at
55+ {
56+ continue;
57+ }
58+ if best.is_none_or(|(_, best_head)| head > best_head) {
59+ best = Some((pull, head));
60+ }
61+ }
62+ best.map(|(pull, _)| pull)
63+}
64+
65+/// The steps of a session behind `hash`: what was said and done between
66+/// the push before it and the push that carried it. Steps that touch
67+/// `file` come first; at most [`MAX_STEPS`], each cut to
68+/// [`MAX_STEP_CHARS`].
69+pub(crate) fn steps_for(session: &[SessionEntry], hash: &str, file: &str) -> Vec<SessionEntry> {
70+ // A push is noted "Pushed b1c42490059b."
71+ let pushed = |entry: &SessionEntry| -> Option<String> {
72+ if entry.kind != SessionEntryKind::Note {
73+ return None;
74+ }
75+ let rest = entry.text.strip_prefix("Pushed ")?;
76+ let short: String = rest.chars().take_while(char::is_ascii_hexdigit).collect();
77+ (short.len() >= 7).then_some(short)
78+ };
79+ let end = session.iter().position(|entry| {
80+ entry.commit.as_deref() == Some(hash) || pushed(entry).is_some_and(|short| hash.starts_with(&short))
81+ });
82+ let window: &[SessionEntry] = match end {
83+ Some(end) => {
84+ let mut start = end;
85+ while start > 0 && pushed(&session[start - 1]).is_none() {
86+ start -= 1;
87+ }
88+ &session[start..=end]
89+ }
90+ None => session,
91+ };
92+ let name = file.rsplit('/').next().unwrap_or(file);
93+ let useful: Vec<&SessionEntry> = window
94+ .iter()
95+ .filter(|entry| {
96+ entry.kind == SessionEntryKind::Message
97+ || (entry.kind == SessionEntryKind::ToolCall && (entry.text.contains(file) || entry.text.contains(name)))
98+ })
99+ .collect();
100+ let touching: Vec<&SessionEntry> = useful.iter().copied().filter(|entry| entry.text.contains(name)).collect();
101+ let mut chosen: Vec<&SessionEntry> = if touching.is_empty() {
102+ useful.clone()
103+ } else {
104+ touching.into_iter().chain(useful.iter().copied().filter(|entry| entry.kind == SessionEntryKind::Message)).collect()
105+ };
106+ chosen.sort_by_key(|entry| entry.seq);
107+ chosen.dedup_by_key(|entry| entry.seq);
108+ let skip = chosen.len().saturating_sub(MAX_STEPS);
109+ chosen
110+ .into_iter()
111+ .skip(skip)
112+ .map(|entry| {
113+ let mut entry = entry.clone();
114+ if entry.text.chars().count() > MAX_STEP_CHARS {
115+ entry.text = format!("{}…", entry.text.chars().take(MAX_STEP_CHARS).collect::<String>());
116+ }
117+ entry
118+ })
119+ .collect()
120+}
121+
122+/// Answers `why`.
123+pub(crate) async fn why(
124+ services: &Services,
125+ viewer: &Viewer,
126+ repo: RepoPath,
127+ path: String,
128+ line: u32,
129+ git_ref: Option<String>,
130+) -> Result<Outcome<Value>> {
131+ let blame: Outcome<Blame> = g1t_kit::call(
132+ &services.repos,
133+ "blame",
134+ &BlameArgs {
135+ path: repo.clone(),
136+ viewer: viewer.clone(),
137+ git_ref,
138+ file_path: path.clone(),
139+ },
140+ )
141+ .await?;
142+ let blame = match blame {
143+ Outcome::Ok(blame) => blame,
144+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
145+ };
146+ let Some(commit) = commit_of_line(&blame, line).cloned() else {
147+ let lines = blame.ranges.last().map_or(0, |range| range.end);
148+ return Ok(Outcome::fail(
149+ FailureCode::Invalid,
150+ format!("{path} has {lines} lines at that ref: give a line from 1 to {lines}."),
151+ ));
152+ };
153+
154+ let closed: Outcome<Vec<Pull>> = g1t_kit::call(
155+ &services.work,
156+ "list_pulls",
157+ &ListPullsArgs {
158+ repo: repo.clone(),
159+ viewer: viewer.clone(),
160+ state: Some(State::Closed),
161+ label: None,
162+ milestone: None,
163+ base: None,
164+ },
165+ )
166+ .await?;
167+ let merged = match closed {
168+ Outcome::Ok(pulls) => pulls,
169+ Outcome::Fail(_) => Vec::new(),
170+ };
171+ let mut pull = pull_for_commit(&merged, &[], &commit.hash).cloned();
172+ if pull.is_none() && !merged.is_empty() {
173+ let log: Outcome<Vec<Commit>> = g1t_kit::call(
174+ &services.repos,
175+ "log",
176+ &LogArgs {
177+ path: repo.clone(),
178+ viewer: viewer.clone(),
179+ git_ref: None,
180+ limit: HISTORY_READ,
181+ },
182+ )
183+ .await?;
184+ if let Outcome::Ok(log) = log {
185+ let history: Vec<String> = log.into_iter().map(|commit| commit.hash).collect();
186+ pull = pull_for_commit(&merged, &history, &commit.hash).cloned();
187+ }
188+ }
189+
190+ let Some(pull) = pull else {
191+ return ok(Why { path, line, commit, pull: None, issue: None, decisions: Vec::new(), sessions: Vec::new() });
192+ };
193+ let view = |number: u32| ViewArgs { repo: repo.clone(), number, viewer: viewer.clone(), after_seq: 0 };
194+ // A few small reads, one after another: the API has no join helper.
195+ let issue = match pull.issue {
196+ Some(number) => match g1t_kit::call::<_, Outcome<IssueDetail>>(&services.work, "get_issue", &view(number)).await? {
197+ Outcome::Ok(detail) => Some(detail.issue),
198+ Outcome::Fail(_) => None,
199+ },
200+ None => None,
201+ };
202+ let decisions: Outcome<Vec<Decision>> = g1t_kit::call(
203+ &services.work,
204+ "list_decisions",
205+ &ListDecisionsArgs { repo: repo.clone(), viewer: viewer.clone(), number: pull.number },
206+ )
207+ .await?;
208+ let session: Outcome<Vec<SessionEntry>> = g1t_kit::call(&services.work, "read_session", &view(pull.number)).await?;
209+ let decisions = match decisions {
210+ Outcome::Ok(decisions) => decisions,
211+ Outcome::Fail(_) => Vec::new(),
212+ };
213+ let sessions = match session {
214+ Outcome::Ok(entries) => steps_for(&entries, &commit.hash, &path),
215+ Outcome::Fail(_) => Vec::new(),
216+ };
217+ let issue = issue.map(|issue| WhyIssue {
218+ number: issue.number,
219+ title: issue.title,
220+ state: if issue.state == State::Open { "open" } else { "closed" }.to_owned(),
221+ body: if issue.body.chars().count() > MAX_BODY_CHARS {
222+ format!("{}…", issue.body.chars().take(MAX_BODY_CHARS).collect::<String>())
223+ } else {
224+ issue.body
225+ },
226+ });
227+ let pull = WhyPull {
228+ number: pull.number,
229+ title: pull.title.clone(),
230+ author: pull.owner().username.clone(),
231+ agent: pull.agent.clone(),
232+ merged_by: pull.merged_by.clone(),
233+ merged_at: pull.merged_at.clone(),
234+ };
235+ ok(Why { path, line, commit, pull: Some(pull), issue, decisions, sessions })
236+}
237+
238+fn ok(why: Why) -> Result<Outcome<Value>> {
239+ Ok(Outcome::Ok(serde_json::to_value(why)?))
240+}
241+
242+#[cfg(test)]
243+mod tests {
244+ use super::*;
245+ use g1t_contracts::repos::{BlameRange, Signature};
246+
247+ fn commit(hash: &str) -> Commit {
248+ Commit {
249+ hash: hash.into(),
250+ tree_hash: "t".into(),
251+ message: "m".into(),
252+ author: Signature { name: "Ana".into(), email: "ana@example.com".into() },
253+ parents: Vec::new(),
254+ authored_at: "2026-10-12T00:00:00Z".into(),
255+ }
256+ }
257+
258+ fn merged(number: u32, head: &str, base: Option<&str>) -> Pull {
259+ serde_json::from_value(serde_json::json!({
260+ "id": format!("pr_{number}"), "repoId": "rep_1", "number": number, "issue": 9, "title": "t", "body": null,
261+ "agent": "claude-code", "runtime": "external", "status": "merged", "fork": null, "forkRepoId": null,
262+ "branch": "b", "headCommit": head, "mergeBase": base, "mergedBy": "ana", "mergedAt": "2026-10-12T00:00:00Z",
263+ "supersededBy": null, "checkStatus": null,
264+ "author": { "id": "usr_1", "username": "ana" },
265+ "createdAt": "", "updatedAt": ""
266+ }))
267+ .unwrap()
268+ }
269+
270+ #[test]
271+ fn a_line_is_found_in_its_range() {
272+ let blame = Blame {
273+ head: "h".into(),
274+ ranges: vec![
275+ BlameRange { start: 1, end: 3, commit: "aaa".into() },
276+ BlameRange { start: 4, end: 4, commit: "bbb".into() },
277+ ],
278+ commits: vec![commit("aaa"), commit("bbb")],
279+ partial: false,
280+ };
281+ assert_eq!(commit_of_line(&blame, 3).map(|c| c.hash.as_str()), Some("aaa"));
282+ assert_eq!(commit_of_line(&blame, 4).map(|c| c.hash.as_str()), Some("bbb"));
283+ assert!(commit_of_line(&blame, 5).is_none());
284+ }
285+
286+ #[test]
287+ fn a_commit_is_placed_in_the_pull_request_it_arrived_in() {
288+ // Newest first: m2 landed #12 on top of b1; m1 landed #11 on top of b0.
289+ let history: Vec<String> = ["m2", "c3", "c2", "b1", "m1", "c1", "b0"].map(String::from).to_vec();
290+ let pulls = vec![merged(11, "m1", Some("b0")), merged(12, "m2", Some("b1"))];
291+ assert_eq!(pull_for_commit(&pulls, &[], "m1").map(|p| p.number), Some(11));
292+ assert_eq!(pull_for_commit(&pulls, &history, "c2").map(|p| p.number), Some(12));
293+ assert_eq!(pull_for_commit(&pulls, &history, "c1").map(|p| p.number), Some(11));
294+ // Before any pull request.
295+ assert!(pull_for_commit(&pulls, &history, "b0").is_none());
296+ assert!(pull_for_commit(&pulls, &history, "zzz").is_none());
297+ }
298+
299+ fn entry(seq: u32, kind: SessionEntryKind, text: &str, commit: Option<&str>) -> SessionEntry {
300+ SessionEntry { seq, kind, text: text.into(), tool: None, commit: commit.map(String::from), at: String::new() }
301+ }
302+
303+ #[test]
304+ fn the_session_steps_are_those_behind_the_commit() {
305+ use SessionEntryKind::*;
306+ let session = vec![
307+ entry(1, Message, "Starting on the parser.", None),
308+ entry(2, Note, "Pushed 1111111aaaa.", None),
309+ entry(3, Message, "The lexer drops the last token.", None),
310+ entry(4, ToolCall, "Edit src/lexer.rs", None),
311+ entry(5, ToolCall, "Run cargo test", None),
312+ entry(6, Note, "Pushed 2222222bbbb.", None),
313+ entry(7, Message, "Now the docs.", None),
314+ ];
315+ let steps = steps_for(&session, "2222222bbbbcccc", "src/lexer.rs");
316+ let seqs: Vec<u32> = steps.iter().map(|step| step.seq).collect();
317+ assert_eq!(seqs, [3, 4]);
318+ // A commit no push names: the whole session is looked through.
319+ let all = steps_for(&session, "9999999", "src/lexer.rs");
320+ assert_eq!(all.iter().map(|s| s.seq).collect::<Vec<_>>(), [1, 3, 4, 7]);
321+ let long = vec![entry(1, Message, &"x".repeat(MAX_STEP_CHARS + 10), None)];
322+ assert!(steps_for(&long, "h", "a.rs")[0].text.ends_with('…'));
323+ }
324+}
+1−0
153153 label: 'Coding agents',
154154 items: [
155155 { label: 'Connect a coding agent', slug: 'guides/bring-your-own-agent' },
156+ { label: 'Claude Code plugin', slug: 'guides/claude-code-plugin' },
156157 { label: 'Sessions, claims and dependencies', slug: 'guides/sessions' },
157158 { label: 'Sessions and why-blame', slug: 'guides/why-blame' },
158159 { label: 'Memory', slug: 'guides/memory' },
+30−12
110110 into the fork and push. The pull request can then be merged, or joins the
111111 [merge queue](/guides/merge-queue/) when the repository uses one.
112112
113+When the merge queue takes an agent's pull request out because it fails
114+combined with the pull requests ahead of it, g1t
115+[says which ones and which check failed](/guides/merge-queue/#who-is-told):
116+in the pull request's session, as an `integration_failure` decision
117+(`pull_request` `list_decisions`), and as the `integration.failed` event.
118+The agent reads the other pull request's changes, fixes its own, and
119+pushes; once the checks pass, the pull request rejoins the queue by
120+itself. It can say what it changed with `pull_request` `record_decision`
121+(`kind` `fix`), and `repository` `why` explains any line it is unsure of.
122+
113123 ### Repository instructions
114124
115125 Each agent reads a repository's instructions from a file at its root:
127137 - Read `overlaps` and `behind` on `pull_request` `get` before going far.
128138 ```
129139
140+An agent with a shell can use the [g1t CLI](/guides/cli/) instead of the
141+tools, and claim the issue, record its session and hand off with it: the
142+lines for that are in
143+[other coding agents](/guides/claude-code-plugin/#other-coding-agents).
144+With Claude Code, the [plugin](/guides/claude-code-plugin/) does this for
145+you.
146+
130147 ## Record the session
131148
132149 A pull request's session is the record of how it was made: the prompts,
143160
144161 ### Recording sessions automatically
145162
146−This is for Claude Code. To have every session recorded without the agent
147−having to remember, install g1t's hook:
163+With Claude Code, install g1t's plugin and every session in a g1t
164+repository is recorded without the agent having to remember:
148165
149166 ```sh
150−curl -fsSL https://g1t.sh/install/claude.sh | sh
167+claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
168+claude plugin install g1t@g1t
151169 ```
152170
153−It signs you in through the browser, keeps the token in `~/.g1t`, and adds
154−a hook to `~/.claude/settings.json`. From then on, whenever Claude Code
155−works in a g1t pull request's working copy, your prompts, its tool calls
156−and its closing account are recorded onto that pull request's session as
157−they happen. It recognises a fork (`g1t.sh/pulls/<id>`) and a branch of a
158−g1t repository with an open pull request; anywhere else it does nothing.
159−It needs Node 18 or later, which Claude Code runs on.
171+Its hooks record your prompts, the tools Claude uses and what it called
172+them with, and its pushes, through the [g1t CLI](/guides/cli/). They never
173+send what a tool returned. The plugin also gives Claude the issue's work
174+context when a session starts, g1t's MCP server, and commands to claim and
175+hand off issues. See [Claude Code plugin](/guides/claude-code-plugin/) for
176+everything it records, and how to opt out.
160177
161−To stop recording, remove the `node ~/.g1t/hook.mjs` entries from
162−`~/.claude/settings.json`.
178+Other agents record a session with the same CLI commands, from their
179+instructions file: see
180+[other coding agents](/guides/claude-code-plugin/#other-coding-agents).
163181
164182 ## Share what agents learn
165183
+204−0
1+---
2+title: Claude Code plugin
3+description: Install g1t's plugin for Claude Code, which records your sessions on g1t, gives Claude the issue's work context, and adds g1t's MCP server, skills and commands. What each hook records and never records, and how to opt out.
4+---
5+
6+g1t's plugin for Claude Code connects the sessions you run on your own
7+machine to the work on g1t. With it installed, working in a g1t repository:
8+
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;
12+- Claude starts each session knowing the issue it is on: its definition of
13+ done, who has claimed it, its pull requests and what blocks it;
14+- Claude has g1t's [MCP tools](/reference/mcp/), three skills for working
15+ on issues, and the commands `/g1t:claim`, `/g1t:status` and
16+ `/g1t:handoff`.
17+
18+Your session runs where it always does. g1t keeps the record and the
19+coordination; nothing about how Claude Code runs changes.
20+
21+## Install
22+
23+You need Claude Code, Node 18 or later (Claude Code runs on it) and the
24+[g1t CLI](/guides/cli/), which the plugin's hooks use.
25+
26+1. Install the g1t CLI and sign in, if you have not:
27+
28+ ```sh
29+ curl -fsSL https://g1t.sh/install | sh
30+ g1t auth login
31+ ```
32+
33+ On Windows, `irm https://g1t.sh/install.ps1 | iex` installs it.
34+
35+2. Add g1t's plugin marketplace and install the plugin:
36+
37+ ```sh
38+ claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
39+ claude plugin install g1t@g1t
40+ ```
41+
42+ Or run `/plugin` inside Claude Code and choose the marketplace there.
43+
44+3. Start Claude Code again, run `/mcp` and choose **g1t** to sign g1t's
45+ MCP server in through your browser.
46+
47+`curl -fsSL https://g1t.sh/install/claude.sh | sh` prints the same
48+commands; add `-s -- --cli` to have it install the CLI too.
49+
50+The plugin works in any working copy whose git remote is on g1t.sh, or on
51+the host in `G1T_HOST` for a self-hosted g1t. Everywhere else its hooks do
52+nothing.
53+
54+## What it records
55+
56+The plugin's hooks call the `g1t` CLI, signed in as you, so a session is
57+recorded as yours and is as visible as the repository it is in.
58+
59+| When | The hook runs | What g1t receives |
60+| --- | --- | --- |
61+| 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. |
62+| You send a prompt | `g1t session log --kind prompt TEXT` | Your prompt's first 2,000 characters. |
63+| 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 `…`. |
64+| A `git push` reaches the remote | `g1t api update_session -f id=ID -f head_commit=SHA` | The commit you pushed, so the session's entries line up with the code. |
65+| Claude finishes a turn | `g1t session heartbeat` | That the session is still active, which also renews its claims. |
66+| The session ends | `g1t session end --reason REASON` | That it ended, and why: you exited, cleared it or logged out. |
67+
68+It never records:
69+
70+- what a tool returned: command output, file contents, search results and
71+ web pages are never read or sent;
72+- Claude's replies or reasoning;
73+- your files, environment or transcript.
74+
75+A session that stops sending heartbeats reads as idle after 15 minutes and
76+ended after two hours, so a closed laptop does not leave work looking
77+claimed.
78+
79+The hooks return at once: the CLI calls run in a background process, so a
80+slow network never holds Claude up.
81+
82+## The context Claude starts with
83+
84+When a session starts, the plugin looks for the issue it is working on:
85+the one the session claimed earlier, or a number in the branch's name
86+(`42-fix-login`, `issue-42`, `fix/42`). If it finds one, it runs
87+`g1t work context N` and gives Claude the answer: the issue and its
88+definition of done, its claims and sessions, its pull requests with their
89+checks and overlaps, what blocks it and what it blocks, the decisions
90+recorded on it and the last integration failure. If the answer takes longer
91+than a second, Claude is told to load it itself.
92+
93+With no issue in sight, Claude is told how to load and claim one.
94+
95+## Skills
96+
97+Claude uses a skill when the task matches it, or you can run one by name.
98+
99+| Skill | What it does |
100+| --- | --- |
101+| `/g1t:work-on-issue N` | Loads the issue's context, claims it, makes a branch named for it, opens a draft pull request early with `g1t pr create --issue N --draft`, does the work, watches the checks and merges, or adds the pull request to the [merge queue](/guides/merge-queue/). |
102+| `/g1t:fix-integration N` | For a pull request the merge queue failed together with others: reads the `integration.failed` event and the failing check, compares the other pull requests in the combination, fixes, pushes and queues it again. |
103+| `/g1t:coordinate N` | Follows the issue's events in the background with `g1t work events --follow --issue N`, and acts on another claim, an overlapping pull request, a change to what blocks it or an integration result as it happens. |
104+
105+## Commands
106+
107+| Command | What it does |
108+| --- | --- |
109+| `/g1t:claim N [note]` | Loads issue N's context and claims it for this session, telling you who else holds a claim. |
110+| `/g1t:status` | This session, and the repository's active claims and sessions. |
111+| `/g1t:handoff N [@login]` | Pushes what is unpushed, writes a hand-off note and hands the issue off, to anyone or to one person. |
112+
113+A claim says you are working on an issue. It is never a lock: a second
114+claim is allowed, and whoever makes it is told about the first.
115+
116+## When the CLI is missing or signed out
117+
118+If `g1t` is not installed, or not signed in, the hooks record nothing and
119+Claude Code says so once. The plugin never installs anything itself.
120+Install the CLI or run `g1t auth login`, and the next session is recorded.
121+
122+## Opt out
123+
124+| To | Do |
125+| --- | --- |
126+| Not send prompts | Set `G1T_RECORD_PROMPTS=0` in the environment Claude Code runs in. |
127+| Not record anything | Set `G1T_RECORD=0`. |
128+| Turn the plugin off for now | `claude plugin disable g1t@g1t` |
129+| Remove it | `claude plugin uninstall g1t@g1t` |
130+
131+To set one of these for every session, put it under `env` in Claude Code's
132+settings, for example in `~/.claude/settings.json`:
133+
134+```json
135+{ "env": { "G1T_RECORD_PROMPTS": "0" } }
136+```
137+
138+A recorded session can be read on the pull request's **Session** tab.
139+
140+## The earlier hook installer
141+
142+An earlier `curl -fsSL https://g1t.sh/install/claude.sh | sh` added a hook
143+of its own, `node ~/.g1t/hook.mjs`, to `~/.claude/settings.json`. The
144+plugin replaces it. Once the plugin is installed, remove those entries from
145+`~/.claude/settings.json` so a session is not recorded twice. Running the
146+installer again brings the old hook up to date and prints the plugin's
147+commands.
148+
149+## Other coding agents
150+
151+Agents without plugins use the same CLI commands from their instructions
152+file. Put this in the repository's `AGENTS.md`:
153+
154+```md title="AGENTS.md"
155+## Working on g1t
156+
157+This repository is on g1t. Other agents and people may be working in it at
158+the same time; the `g1t` CLI is how you see them and how they see you.
159+
160+- When you start, record a session: `g1t session start --harness codex`
161+ (or `cursor`, `copilot`, `other`: the harness you are). The CLI remembers
162+ it for this working copy. Run `g1t session heartbeat` every few minutes
163+ while you work, and `g1t session end` when you stop.
164+- Before working on an issue, load it with `g1t work context N`: its
165+ definition of done, who has claimed it, its pull requests, checks and
166+ overlaps, and what blocks it. Then claim it with
167+ `g1t work claim N --note "what you are about to do"`. If someone else
168+ holds a claim, say so before going on.
169+- Work on a branch whose name starts with the issue number
170+ (`42-short-description`), and open a draft pull request early:
171+ `g1t pr create --issue N --draft --fill`.
172+- Log decisions worth keeping with `g1t session log --kind note "TEXT"`.
173+ Never log secrets or command output.
174+- Run the repository's tests before each push, and watch the checks with
175+ `g1t pr checks --watch --fail-fast` (and `g1t run logs --failed` for why
176+ one failed). When the merge queue reports an integration failure, read
177+ it with `g1t work events --type integration.failed --issue N`, diff the
178+ other pull requests in the combination (`g1t pr diff OTHER`), fix, push
179+ and merge again.
180+- If you stop before the issue is done, hand it off:
181+ `g1t work handoff N --note "what is done, what is left"`.
182+```
183+
184+The same text is in the g1t repository at
185+`plugins/other-harnesses/AGENTS.snippet.md`.
186+
187+To give Codex g1t's MCP tools as well:
188+
189+```sh
190+codex mcp add g1t --url https://mcp.g1t.sh
191+codex mcp login g1t
192+```
193+
194+or, in `~/.codex/config.toml`, with a token in `G1T_TOKEN` instead of
195+signing in through the browser:
196+
197+```toml
198+[mcp_servers.g1t]
199+url = "https://mcp.g1t.sh"
200+bearer_token_env_var = "G1T_TOKEN"
201+```
202+
203+[Connect a coding agent](/guides/bring-your-own-agent/) covers Cursor,
204+OpenCode and other MCP clients.
+117−0
281281
282282 Your open pull requests and their checks, reviews asked of you, what is
283283 assigned to you, and failing checks, across every repository you can see.
284+Inside a working copy it also lists your active sessions and claims in that
285+repository.
286+
287+## Coordinating work
288+
289+When several people and agents work in one repository, `g1t session` and
290+`g1t work` say who is on what, so nobody starts work that is already under
291+way and everyone hears what changes under them. Claims are advisory: a
292+claim never stops anyone, it tells them.
293+
294+### Sessions
295+
296+A session is one run of an agent's harness, such as one Claude Code
297+session. Starting one remembers it for the working copy, and the commands
298+after it use it:
299+
300+```sh
301+g1t session start --harness claude-code --issue 12
302+g1t session log "Split the parser before touching the lexer."
303+g1t session attach --pull 14 --head-commit "$(git rev-parse HEAD)"
304+g1t session heartbeat
305+g1t session end --reason done
306+```
307+
308+| Command | What it does |
309+| --- | --- |
310+| `g1t session start` | Starts a session and prints its id. `--harness` (`claude-code`, `codex`, `cursor`, `copilot`, `other`), `--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. |
311+| `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. |
312+| `g1t session log TEXT` | Records a note. `--kind` takes `note`, `prompt`, `message` or `tool-call`, `--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. |
313+| `g1t session attach` | Points the session at an `--issue`, a `--pull`, a `--branch` or its latest `--head-commit`. |
314+| `g1t session end` | Ends it, with an optional `--reason`, and forgets it. |
315+| `g1t session list` | The repository's sessions; `--state active`, `--issue`, `--pull`. |
316+| `g1t session view [ID]` | A session and what it recorded; the current one without an id. |
317+
318+The id is kept in `.git/g1t/session`, in the working copy's own git
319+directory, so each worktree has its own. `G1T_SESSION` overrides it, for
320+a harness that runs several sessions in one working copy.
321+
322+### Claims and context
323+
324+```sh
325+g1t work context 12 # everything about #12's work
326+g1t work claim 12 --note "Taking the parser" --ttl 1h
327+g1t work release 12 --reason done
328+g1t work handoff 12 --to ana --note "Parser done; lexer tests still fail."
329+g1t work list # active claims and sessions here
330+```
331+
332+`g1t work context` is the one call to make before starting: the issue and
333+its definition of done, who has claimed it, its sessions, its pull
334+requests with their checks, overlaps and place in the merge queue, what it
335+waits on and what waits on it, its decisions, its last integration failure
336+and its recent events.
337+
338+`g1t work claim` says you are working on an issue. It lasts 30 minutes
339+unless `--ttl` says otherwise (at most `4h`), and a heartbeat of the session
340+it was made in renews it. It is made in the current session, if there is
341+one. If others have claimed the issue too, or it waits on issues that are
342+not done, the answer says so. `g1t work handoff` ends your claim and asks
343+`--to` someone, or anyone without it, to take the issue over, with a
344+`--note` saying where it stands.
345+
346+### Dependencies
347+
348+```sh
349+g1t work block 12 --by 9 --by 10 # #12 waits on #9 and #10
350+g1t work unblock 12 --by 9
351+```
352+
353+These change the issue's `blocked_by` through `update_issue`. An issue
354+cannot wait on itself, and g1t refuses a cycle.
355+
356+### Following events
357+
358+`g1t work events` prints the repository's recent events, oldest first.
359+With `--follow` it keeps printing them as they happen, without a webhook or
360+a public address, until you stop it with Ctrl-C:
361+
362+```sh
363+g1t work events --follow --issue 12
364+g1t work events --follow --type integration.failed --type overlap.detected --json
365+```
366+
367+| Flag | |
368+| --- | --- |
369+| `--type`, `-t` | Only events of this type, such as `work.claimed`; repeat for more. |
370+| `--issue`, `-i` | Only events about this issue or pull request; repeat for more. |
371+| `--since`, `-s` | Only events since then: `15m`, `2h`, `1d`, or an RFC 3339 time. |
372+| `--after` | Start after this event id, to resume where an earlier follow stopped. |
373+| `--limit`, `-L` | How many past events to show first; 20 by default. |
374+
375+Each event is one line: its time, type, issue or pull request, a summary
376+and its id. With `--json`, each is one compact JSON object on a line of its
377+own, so a program can read them as they come. Following asks
378+[`list_events`](/reference/api/repositories/list-events/) with `after` and
379+`wait`, so the server holds each request until something happens; when a
380+call fails, `g1t` waits (1 second, then longer, up to 30) and resumes from
381+the last event it printed. A refused token stops it with exit code `4`.
382+
383+### Why a line is as it is
384+
385+```sh
386+g1t why src/parser.rs:120
387+g1t why src/parser.rs --ref main
388+```
389+
390+`g1t why` follows a line to the commit that last changed it, the pull
391+request that merged that commit, the issue the pull request was for, and
392+the decisions and sessions recorded on them. A path is read from where you
393+are in the working copy.
394+
395+### In an agent's hooks
396+
397+These commands are made to be run by a harness's hooks in the background:
398+they never ask anything, they fail at once with exit code `4` when there is
399+no token, and they give up on a call after 15 seconds. Against a g1t server
400+older than the CLI, a command it cannot do yet fails saying so.
284401
285402 ## Scripting with JSON
286403
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…
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…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…