Skip to content

Commit

Merge branch 'worktree-agent-ad52ad80841a37960'

syntaqxcommitted Parents52dfdb0cac4250Browse files
24 files+1275−1470/24 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
+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 and why-blame', slug: 'guides/why-blame' },
157158 { label: 'Memory', slug: 'guides/memory' },
158159 ],
+20−12
124124 - Read `overlaps` and `behind` on `pull_request` `get` before going far.
125125 ```
126126
127+An agent with a shell can use the [g1t CLI](/guides/cli/) instead of the
128+tools, and claim the issue, record its session and hand off with it: the
129+lines for that are in
130+[other coding agents](/guides/claude-code-plugin/#other-coding-agents).
131+With Claude Code, the [plugin](/guides/claude-code-plugin/) does this for
132+you.
133+
127134 ## Record the session
128135
129136 A pull request's session is the record of how it was made: the prompts,
140147
141148 ### Recording sessions automatically
142149
143−This is for Claude Code. To have every session recorded without the agent
144−having to remember, install g1t's hook:
150+With Claude Code, install g1t's plugin and every session in a g1t
151+repository is recorded without the agent having to remember:
145152
146153 ```sh
147−curl -fsSL https://g1t.sh/install/claude.sh | sh
154+claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
155+claude plugin install g1t@g1t
148156 ```
149157
150−It signs you in through the browser, keeps the token in `~/.g1t`, and adds
151−a hook to `~/.claude/settings.json`. From then on, whenever Claude Code
152−works in a g1t pull request's working copy, your prompts, its tool calls
153−and its closing account are recorded onto that pull request's session as
154−they happen. It recognises a fork (`g1t.sh/pulls/<id>`) and a branch of a
155−g1t repository with an open pull request; anywhere else it does nothing.
156−It needs Node 18 or later, which Claude Code runs on.
158+Its hooks record your prompts, the tools Claude uses and what it called
159+them with, and its pushes, through the [g1t CLI](/guides/cli/). They never
160+send what a tool returned. The plugin also gives Claude the issue's work
161+context when a session starts, g1t's MCP server, and commands to claim and
162+hand off issues. See [Claude Code plugin](/guides/claude-code-plugin/) for
163+everything it records, and how to opt out.
157164
158−To stop recording, remove the `node ~/.g1t/hook.mjs` entries from
159−`~/.claude/settings.json`.
165+Other agents record a session with the same CLI commands, from their
166+instructions file: see
167+[other coding agents](/guides/claude-code-plugin/#other-coding-agents).
160168
161169 ## Share what agents learn
162170
+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.
+1−1
5353
5454 | Category | Workspace | Personal |
5555 | --- | --- | --- |
56−| Code & CI | [GitHub](/guides/github/) import, [webhooks](/guides/webhooks/) | [Signing in with GitHub](/guides/github/#link-and-unlink-github); [MCP clients](/guides/authentication/#signing-in-with-oauth): your [coding agent](/guides/bring-your-own-agent/), connected to g1t as you |
56+| Code & CI | [GitHub](/guides/github/) import, [webhooks](/guides/webhooks/) | [Signing in with GitHub](/guides/github/#link-and-unlink-github); [MCP clients](/guides/authentication/#signing-in-with-oauth): your [coding agent](/guides/bring-your-own-agent/), connected to g1t as you; [Claude Code plugin](/guides/claude-code-plugin/) |
5757 | Issues & projects | [Jira and Linear](#trackers) | |
5858 | Monitoring & incidents | [Sentry, Datadog, and any system that sends a webhook](#alerts) | |
5959 | AI models | [Your AI providers](#your-ai-providers): Anthropic, OpenAI, Google Gemini, xAI, Mistral, DeepSeek, Azure OpenAI, OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, and any Anthropic- or OpenAI-compatible endpoint | |
+6−5
3333
3434 Your coding agent records its session in one of two ways.
3535
36−**With the hook installer**, for Claude Code. Every session is recorded
37−without the agent having to remember:
36+**With g1t's Claude Code plugin.** Every session is recorded without the
37+agent having to remember:
3838
3939 ```sh
40−curl -fsSL https://g1t.sh/install/claude.sh | sh
40+claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
41+claude plugin install g1t@g1t
4142 ```
4243
43−See [recording sessions automatically](/guides/bring-your-own-agent/#recording-sessions-automatically)
44−for what it installs and how to remove it.
44+See [Claude Code plugin](/guides/claude-code-plugin/) for what it records,
45+what it never records and how to opt out.
4546
4647 **With the `pull_request` tool's `record_session` action**, from any agent. It takes a list of entries, each
4748 with a `kind` from the table above and `text`, and `tool` for tool entries.
+30−13
11 #!/bin/sh
2−# Connects Claude Code on this machine to g1t, so the sessions of your own
3−# agent are recorded onto the pull requests they belong to:
2+# Connects Claude Code on this machine to g1t. g1t's Claude Code plugin
3+# records your sessions on g1t, loads an issue's work context, and adds
4+# g1t's MCP server and commands; this prints the two commands that install
5+# it. With --cli it also installs the g1t CLI, which the plugin's hooks use:
46 #
57 # curl -fsSL https://g1t.sh/install/claude.sh | sh
8+# curl -fsSL https://g1t.sh/install/claude.sh | sh -s -- --cli
69 #
7−# It downloads two small scripts into ~/.g1t, signs you in through the
8−# browser, and adds a hook to ~/.claude/settings.json. Needs Node 18 or
9−# later, which Claude Code itself runs on.
10+# It changes nothing else. An earlier version of this script installed a
11+# hook in ~/.g1t; if that is there, it is brought up to date, and you can
12+# remove it once the plugin is installed (see below).
1013 set -eu
1114
1215 BASE="${G1T_SITE:-https://g1t.sh}"
1316
14−if ! command -v node >/dev/null 2>&1; then
15− echo "g1t: Node 18 or later is needed (Claude Code runs on it too)." >&2
16− exit 1
17+if [ "${1:-}" = "--cli" ]; then
18+ curl -fsSL "$BASE/install" | sh
1719 fi
1820
19−mkdir -p "$HOME/.g1t"
20−curl -fsSL "$BASE/install/hook.mjs" -o "$HOME/.g1t/hook.mjs"
21−curl -fsSL "$BASE/install/setup.mjs" -o "$HOME/.g1t/setup.mjs"
22−# Read from the terminal, not from the pipe this script arrived on.
23−node "$HOME/.g1t/setup.mjs" < /dev/tty
21+if [ -f "$HOME/.g1t/hook.mjs" ]; then
22+ curl -fsSL "$BASE/install/hook.mjs" -o "$HOME/.g1t/hook.mjs"
23+ echo "Updated the earlier g1t hook in ~/.g1t/hook.mjs. The plugin replaces it:"
24+ echo "once the plugin is installed, remove the \`node \"$HOME/.g1t/hook.mjs\"\`"
25+ echo "entries from ~/.claude/settings.json, so a session is not recorded twice."
26+ echo
27+fi
28+
29+cat <<EOF
30+Install g1t's plugin for Claude Code:
31+
32+ claude plugin marketplace add $BASE/flagon-io/g1t.git
33+ claude plugin install g1t@g1t
34+
35+The plugin's hooks use the g1t CLI. If you do not have it yet, install it
36+(curl -fsSL $BASE/install | sh) and sign in with \`g1t auth login\`.
37+Then, in Claude Code, run /mcp and choose g1t to sign its tools in.
38+
39+Guide: https://docs.g1t.sh/guides/claude-code-plugin/
40+EOF
+18−11
88 // of a g1t repository with an open pull request. Anywhere else it does
99 // nothing. It never fails the agent's turn.
1010 //
11−// Installed by `curl -fsSL https://g1t.sh/install/claude.sh | sh`.
11+// It records what each tool was called with, never what it returned.
12+//
13+// Installed by an earlier `curl -fsSL https://g1t.sh/install/claude.sh | sh`.
14+// g1t's Claude Code plugin replaces it:
15+// https://docs.g1t.sh/guides/claude-code-plugin/
1216
1317 import { execFileSync } from "node:child_process";
1418 import { existsSync, readFileSync, writeFileSync } from "node:fs";
5155 return clip(input);
5256 }
5357
54−/** The pull request this working copy belongs to, if any. Cached per copy. */
58+/**
59+ * The pull request this working copy belongs to, if any. Only a pull
60+ * request found is cached: a branch whose pull request is opened later in
61+ * the session is looked up again, and recorded from then on.
62+ */
5563 async function pullFor(cwd, config) {
5664 const remote = git(cwd, "remote", "get-url", "origin");
5765 const branch = git(cwd, "branch", "--show-current");
5866 if (!remote.includes("g1t.sh/")) return null;
5967 const key = `${remote}#${branch}`;
6068 const cache = read(CACHE, {});
61− if (key in cache) return cache[key];
69+ if (cache[key]) return cache[key];
6270 const headers = { authorization: `Bearer ${config.token}` };
6371 let found = null;
6472 const fork = /g1t\.sh\/pulls\/([a-z0-9_]+?)(?:\.git)?$/.exec(remote);
7785 if (pull) found = { repo: `${repo[1]}/${repo[2]}`, number: pull.number };
7886 }
7987 }
80− cache[key] = found;
81− try {
82− writeFileSync(CACHE, JSON.stringify(cache));
83− } catch {}
88+ if (found) {
89+ cache[key] = found;
90+ try {
91+ writeFileSync(CACHE, JSON.stringify(cache));
92+ } catch {}
93+ }
8494 return found;
8595 }
8696
116126 const entries = [];
117127 switch (event.hook_event_name) {
118128 case "UserPromptSubmit":
119− if (event.prompt) entries.push({ kind: "prompt", text: clip(event.prompt) });
129+ if (event.prompt && process.env.G1T_RECORD_PROMPTS !== "0") entries.push({ kind: "prompt", text: clip(event.prompt) });
120130 break;
121131 case "PostToolUse":
122132 entries.push({ kind: "tool_call", tool: event.tool_name, text: clip(describeCall(event.tool_name, event.tool_input)) });
123− if (event.tool_response != null) {
124− entries.push({ kind: "tool_result", tool: "result", text: clip(event.tool_response) });
125− }
126133 break;
127134 case "Stop": {
128135 const message = lastMessage(event.transcript_path);
+0−97
1−#!/usr/bin/env node
2−// Sets up g1t for Claude Code on this machine: signs in through the
3−// browser (nothing to paste), keeps the token in ~/.g1t, and adds g1t's
4−// hook to ~/.claude/settings.json so sessions are recorded onto the pull
5−// requests they belong to. Run by https://g1t.sh/install/claude.sh.
6−
7−import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8−import { homedir } from "node:os";
9−import { join } from "node:path";
10−
11−const API = process.env.G1T_API ?? "https://api.g1t.sh";
12−const HOME = join(homedir(), ".g1t");
13−const CONFIG = join(HOME, "config.json");
14−const SETTINGS = join(homedir(), ".claude", "settings.json");
15−const HOOK = `node "${join(HOME, "hook.mjs")}"`;
16−
17−const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
18−
19−async function signIn() {
20− if (existsSync(CONFIG)) {
21− try {
22− const config = JSON.parse(readFileSync(CONFIG, "utf8"));
23− const me = await fetch(`${API}/user`, { headers: { authorization: `Bearer ${config.token}` } });
24− if (me.ok) {
25− console.log(`Already signed in as ${(await me.json()).username}.`);
26− return;
27− }
28− } catch {}
29− }
30− const started = await (
31− await fetch(`${API}/device/code`, {
32− method: "POST",
33− headers: { "content-type": "application/json" },
34− body: JSON.stringify({ client_name: "Claude Code on this machine" }),
35− })
36− ).json();
37− console.log(`\nOpen ${started.verification_uri_complete}`);
38− console.log(`and check that the code there is ${started.user_code}.\n`);
39− const deadline = Date.now() + started.expires_in * 1000;
40− while (Date.now() < deadline) {
41− await sleep(Math.max(started.interval, 2) * 1000);
42− const claim = await (
43− await fetch(`${API}/device/token`, {
44− method: "POST",
45− headers: { "content-type": "application/json" },
46− body: JSON.stringify({ device_code: started.device_code }),
47− })
48− ).json();
49− if (claim.status === "approved") {
50− mkdirSync(HOME, { recursive: true });
51− writeFileSync(CONFIG, JSON.stringify({ api: API, token: claim.token }, null, 2));
52− try {
53− chmodSync(CONFIG, 0o600);
54− } catch {}
55− console.log(`Signed in as ${claim.username}.`);
56− return;
57− }
58− if (claim.status === "denied" || claim.status === "expired") {
59− throw new Error(`Sign-in was ${claim.status}.`);
60− }
61− }
62− throw new Error("Sign-in timed out.");
63−}
64−
65−/** Adds g1t's hook to Claude Code's settings, keeping everything else. */
66−function addHooks() {
67− let settings = {};
68− try {
69− settings = JSON.parse(readFileSync(SETTINGS, "utf8"));
70− } catch {}
71− settings.hooks ??= {};
72− for (const [event, matcher] of [
73− ["UserPromptSubmit", undefined],
74− ["PostToolUse", "*"],
75− ["Stop", undefined],
76− ]) {
77− const groups = (settings.hooks[event] ??= []);
78− const present = groups.some((group) => group.hooks?.some((hook) => hook.command === HOOK));
79− if (!present) {
80− groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: HOOK, timeout: 10 }] });
81− }
82− }
83− mkdirSync(join(homedir(), ".claude"), { recursive: true });
84− writeFileSync(SETTINGS, JSON.stringify(settings, null, 2));
85− console.log(`Added g1t's hook to ${SETTINGS}.`);
86−}
87−
88−try {
89− await signIn();
90− addHooks();
91− console.log("\nDone. Sessions in a g1t pull request's working copy are now recorded onto it.");
92− console.log("To give Claude Code g1t's tools as well, run:");
93− console.log(" claude mcp add --transport http g1t https://mcp.g1t.sh\n");
94−} catch (error) {
95− console.error(`g1t: ${error.message}`);
96− process.exit(1);
97−}
+21−8
124124 Or let git ask: the username is the g1t username and the password is the
125125 token.
126126
127−8. **Record Claude Code sessions automatically** (optional). This installs
128− hooks that record prompts, tool calls and replies onto the g1t pull
129− request for the branch being worked on. The person runs it, because it
130− signs them in through their browser:
127+8. **Record Claude Code sessions automatically** (optional). g1t's Claude
128+ Code plugin records each session on g1t (prompts, up to 2,000
129+ characters; tool names and what they were called with, never what they
130+ returned; pushes), gives Claude the issue's work context when a session
131+ starts, and adds g1t's MCP server and the `/g1t:claim`, `/g1t:status`
132+ and `/g1t:handoff` commands. Its hooks use the g1t CLI
133+ (`curl -fsSL https://g1t.sh/install | sh`, then `g1t auth login`). The
134+ person installs it:
131135
132136 ```sh
133− curl -fsSL https://g1t.sh/install/claude.sh | sh
137+ claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
138+ claude plugin install g1t@g1t
134139 ```
135140
141+ `G1T_RECORD_PROMPTS=0` keeps prompts back; `G1T_RECORD=0` records
142+ nothing. Guide: https://docs.g1t.sh/guides/claude-code-plugin/
143+
144+ Other agents with a shell record the same way with the CLI:
145+ `g1t session start --harness codex` when they start,
146+ `g1t work context N` and `g1t work claim N` before working on issue N,
147+ `g1t session log --kind note TEXT` for decisions, `g1t session heartbeat`
148+ while working, `g1t work handoff N --note TEXT` if they stop early, and
149+ `g1t session end` at the end.
150+
136151 ## Do work
137152
138153 Issues and pull requests are addressed by repository and number, and share
292307 `notifications:write`). For CI, the CI preset (`repo:read`, `code:read`,
293308 `code:write`, `packages:read`, `packages:write`, `workflows:read`,
294309 `workflows:write`, `checks:read`, `checks:write`, `deployments:read`,
295−`deployments:write`). A token or application given `agents:run`,
296−`models:read` or `models:write` before keeps working for its other scopes;
297−those three grant nothing.
310+`deployments:write`).
298311 `chat:read` and `chat:write` are in no preset either: with them a person's
299312 token, OAuth or personal, uses Chat's JSON routes (`/{workspace}/-/chat/api`)
300313 and live sockets on the website without "Use the website as you", and
+10−0
1+{
2+ "name": "g1t",
3+ "version": "0.1.0",
4+ "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.",
5+ "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" },
6+ "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/",
7+ "repository": "https://g1t.sh/flagon-io/g1t",
8+ "license": "MIT",
9+ "keywords": ["g1t", "git", "issues", "pull-requests", "sessions", "coordination"]
10+}
+8−0
1+{
2+ "mcpServers": {
3+ "g1t": {
4+ "type": "http",
5+ "url": "https://mcp.g1t.sh"
6+ }
7+ }
8+}
+15−0
1+---
2+description: Claim a g1t issue for this session and load its work context
3+argument-hint: "<issue number> [note]"
4+allowed-tools: Bash(g1t:*)
5+---
6+
7+Claim g1t issue $1 for this session.
8+
9+1. Run `g1t work context $1` and read it: the definition of done, who else
10+ has claimed it, its pull requests and what blocks it.
11+2. Run `g1t work claim $1`, adding `--note "..."` with the rest of these
12+ arguments if there are any: $ARGUMENTS
13+3. Tell me in a few lines what the issue asks for, whether anyone else holds
14+ a claim on it (the claim command lists them), and anything that blocks
15+ it. If someone else is on it, ask me before starting work.
+16−0
1+---
2+description: Hand a g1t issue off to someone else, with a note on where it stands
3+argument-hint: "<issue number> [@login]"
4+allowed-tools: Bash(g1t:*) Bash(git:*)
5+---
6+
7+Hand off g1t issue $1. Arguments: $ARGUMENTS
8+
9+1. Make sure nothing is lost: commit and push work in progress on the
10+ issue's branch, and say if anything could not be pushed.
11+2. Write a hand-off note for whoever picks it up: what is done, what is
12+ left, what you tried that did not work, and anything to watch out for.
13+ Keep it to a few lines.
14+3. Run `g1t work handoff $1 --note "THE NOTE"`, with `--to LOGIN` if a
15+ login (such as `@ana`, given without the `@`) is in the arguments.
16+4. Show me the note you left.
+13−0
1+---
2+description: Show this repository's active g1t claims and sessions, and this session's own
3+allowed-tools: Bash(g1t:*)
4+---
5+
6+Run `g1t status` and `g1t work list`, then summarise in a short list:
7+
8+- this session (`G1T_SESSION`, if set), the issue it claimed and its pull request;
9+- every active claim in the repository: issue, who, how long ago and its note;
10+- other active sessions, and any of them working on the same issue as this one.
11+
12+Point out anything that needs me: two claims on one issue, a claim of mine
13+about to expire, or a pull request of mine with failing checks.
+415−0
1+#!/usr/bin/env node
2+// g1t's Claude Code hooks. Claude Code runs this file for SessionStart,
3+// UserPromptSubmit, PostToolUse, Stop and SessionEnd, with the event as JSON
4+// on stdin. In a working copy of a g1t repository it records the session on
5+// g1t through the `g1t` CLI:
6+//
7+// SessionStart g1t session start --harness claude_code --harness-session-id ID
8+// and, when the branch or the session names an issue,
9+// g1t work context N, given to Claude as context
10+// UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters)
11+// PostToolUse g1t session log --kind tool_call "Tool: what it was called with"
12+// and after a git push that reached the remote,
13+// g1t api update_session -f id=SESSION -f head_commit=SHA
14+// Stop g1t session heartbeat
15+// SessionEnd g1t session end --reason REASON
16+//
17+// What a tool returned (tool_response) is never read or sent. Prompts are
18+// not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0.
19+//
20+// Every hook returns at once: the CLI calls run in a detached process of
21+// their own, so a slow network never holds Claude up. If `g1t` is not
22+// installed or not signed in, nothing is recorded and Claude Code says so
23+// once. Nothing is ever installed. No dependencies; Node 18 or later.
24+
25+import { spawn, spawnSync } from "node:child_process";
26+import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
27+import { homedir } from "node:os";
28+import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
29+import { fileURLToPath } from "node:url";
30+
31+/** The most of a prompt that is sent. */
32+export const PROMPT_LIMIT = 2000;
33+/** The most of a tool call's summary that is sent. */
34+export const SUMMARY_LIMIT = 200;
35+/** The most of an issue's work context given to Claude. */
36+export const CONTEXT_LIMIT = 8000;
37+/** How long SessionStart waits for an issue's work context. */
38+const CONTEXT_WAIT_MS = 900;
39+/** Exit code the g1t CLI gives when signing in is needed. */
40+const EXIT_AUTH = 4;
41+
42+const SELF = fileURLToPath(import.meta.url);
43+
44+// --- what to send ------------------------------------------------------
45+
46+export function clip(text, limit) {
47+ const value = String(text ?? "");
48+ return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
49+}
50+
51+/** Hides what looks like a secret in a command line before it is sent. */
52+export function redact(text) {
53+ return String(text)
54+ .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…")
55+ .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…")
56+ .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…")
57+ .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…")
58+ .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@");
59+}
60+
61+/** One line on what a tool was called with: never what it returned. */
62+export function summarizeTool(name, input, cwd) {
63+ const i = input && typeof input === "object" ? input : {};
64+ const path = (p) => {
65+ if (typeof p !== "string") return "";
66+ if (cwd && isAbsolute(p)) {
67+ const rel = relative(cwd, p);
68+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel.replaceAll("\\", "/");
69+ }
70+ return p;
71+ };
72+ let what = "";
73+ if (typeof i.command === "string") what = i.command.split("\n")[0] + (i.command.includes("\n") ? " …" : "");
74+ else if (i.file_path || i.notebook_path) what = path(i.file_path ?? i.notebook_path);
75+ else if (typeof i.pattern === "string") what = `${i.pattern}${i.path ? ` in ${path(i.path)}` : ""}`;
76+ else if (typeof i.url === "string") what = i.url;
77+ else if (typeof i.query === "string") what = i.query;
78+ else if (typeof i.description === "string") what = i.description;
79+ else if (typeof i.action === "string") what = i.action;
80+ const line = what ? `${name}: ${redact(what)}` : String(name);
81+ return clip(line, SUMMARY_LIMIT);
82+}
83+
84+/** The issue a branch is for, when its name holds the number: 42-fix, issue-42, fix/42, g1t/42-x. */
85+export function issueFromBranch(branch) {
86+ if (!branch || branch === "HEAD") return null;
87+ if (/^(release|releases|hotfix|v\d)/i.test(branch)) return null;
88+ const match = /(?:^|[/_-])(?:issues?-?|#)?(\d{1,7})(?=$|[/_-])/i.exec(branch);
89+ if (!match) return null;
90+ const n = Number(match[1]);
91+ return n > 0 ? n : null;
92+}
93+
94+/** The issue a shell command claims or attaches the session to, if any. */
95+export function issueFromCommand(command) {
96+ if (typeof command !== "string") return null;
97+ const patterns = [
98+ /\bg1t\s+work\s+claim\s+#?(\d+)/,
99+ /\bg1t\s+session\s+(?:start|attach)\b[^|;&\n]*--issue[= ]#?(\d+)/,
100+ /\bg1t\s+pr\s+create\b[^|;&\n]*--issue[= ]#?(\d+)/,
101+ ];
102+ for (const pattern of patterns) {
103+ const match = pattern.exec(command);
104+ if (match) return Number(match[1]);
105+ }
106+ return null;
107+}
108+
109+export const isGitPush = (command) =>
110+ typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+push\b/.test(command);
111+
112+/**
113+ * What a hook event asks for: the CLI calls to run in the background
114+ * (`steps`), and whether to start the session first. Pure, so the tests
115+ * can check it without a CLI.
116+ */
117+export function plan(event, env = process.env) {
118+ const name = event?.hook_event_name;
119+ const sid = event?.session_id;
120+ if (!name || !sid || env.G1T_RECORD === "0") return null;
121+ const cwd = event.cwd || process.cwd();
122+ const job = { cwd, sid, steps: [] };
123+ switch (name) {
124+ case "SessionStart":
125+ job.start = true;
126+ return job;
127+ case "UserPromptSubmit":
128+ if (env.G1T_RECORD_PROMPTS === "0" || typeof event.prompt !== "string" || !event.prompt.trim()) return null;
129+ job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(event.prompt, PROMPT_LIMIT)] });
130+ return job;
131+ case "PostToolUse": {
132+ if (!event.tool_name) return null;
133+ const input = event.tool_input ?? {};
134+ job.steps.push({ args: ["session", "log", "--kind", "tool_call", "--", summarizeTool(event.tool_name, input, cwd)] });
135+ if (event.tool_name === "Bash") {
136+ const issue = issueFromCommand(input.command);
137+ if (issue) job.issue = issue;
138+ if (isGitPush(input.command)) job.steps.push({ headCommit: true });
139+ }
140+ return job;
141+ }
142+ case "Stop":
143+ job.steps.push({ args: ["session", "heartbeat"] });
144+ return job;
145+ case "SessionEnd":
146+ job.steps.push({ args: ["session", "end", "--reason", String(event.reason || "other")] });
147+ job.end = true;
148+ return job;
149+ default:
150+ return null;
151+ }
152+}
153+
154+// --- the machine -------------------------------------------------------
155+
156+function git(cwd, args) {
157+ const out = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, timeout: 3000 });
158+ return out.status === 0 ? out.stdout.trim() : null;
159+}
160+
161+/** The g1t CLI on PATH, without running it. G1T_BIN names another. */
162+export function findCli(env = process.env) {
163+ if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null;
164+ const names = process.platform === "win32" ? ["g1t.exe", "g1t"] : ["g1t"];
165+ for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) {
166+ if (!dir) continue;
167+ for (const name of names) {
168+ const candidate = join(dir.replace(/^"|"$/g, ""), name);
169+ try {
170+ if (statSync(candidate).isFile()) return candidate;
171+ } catch {}
172+ }
173+ }
174+ return null;
175+}
176+
177+/** Runs the CLI. A .js or .mjs G1T_BIN (the tests' stand-in) runs under Node. */
178+function cli(bin, args, options) {
179+ const script = /\.m?js$/.test(bin);
180+ return spawnSync(script ? process.execPath : bin, script ? [bin, ...args] : args, {
181+ encoding: "utf8",
182+ windowsHide: true,
183+ ...options,
184+ });
185+}
186+
187+/** Where the plugin keeps its state: Claude Code's data folder for it. */
188+export function dataDir(env = process.env) {
189+ return env.CLAUDE_PLUGIN_DATA || join(homedir(), ".g1t", "claude-code");
190+}
191+
192+function readJson(path, fallback) {
193+ try {
194+ return JSON.parse(readFileSync(path, "utf8"));
195+ } catch {
196+ return fallback;
197+ }
198+}
199+
200+function write(path, text) {
201+ try {
202+ mkdirSync(dirname(path), { recursive: true });
203+ writeFileSync(path, text);
204+ } catch {}
205+}
206+
207+const readText = (path) => {
208+ try {
209+ return readFileSync(path, "utf8").trim() || null;
210+ } catch {
211+ return null;
212+ }
213+};
214+
215+/** This session's files: its marker (it is in a g1t working copy), its g1t session id and its issue. */
216+export function sessionFiles(sid, env = process.env) {
217+ const dir = join(dataDir(env), "sessions");
218+ const safe = String(sid).replace(/[^A-Za-z0-9_-]/g, "_");
219+ return { dir, marker: join(dir, `${safe}.json`), id: join(dir, `${safe}.id`), issue: join(dir, `${safe}.issue`) };
220+}
221+
222+/** Forgets sessions untouched for a week (ones that never saw SessionEnd). */
223+function prune(dir) {
224+ try {
225+ const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
226+ for (const name of readdirSync(dir)) {
227+ const path = join(dir, name);
228+ if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true });
229+ }
230+ } catch {}
231+}
232+
233+// --- saying so once ----------------------------------------------------
234+
235+const MESSAGES = {
236+ missing:
237+ "g1t: this session is not being recorded on g1t, because the g1t CLI is not installed. Install it (https://docs.g1t.sh/guides/cli/) and run `g1t auth login`. This is said once.",
238+ signed_out:
239+ "g1t: this session is not being recorded on g1t, because the g1t CLI is not signed in. Run `g1t auth login`. This is said once.",
240+};
241+
242+/** Remembers a problem (missing, signed_out), or that there is none (null). */
243+export function setProblem(env, problem) {
244+ const path = join(dataDir(env), "notice.json");
245+ const notice = readJson(path, {});
246+ if ((notice.problem ?? null) === problem) return;
247+ write(path, JSON.stringify({ problem, shown: false }));
248+}
249+
250+/** The message for a problem not shown yet, marking it shown. */
251+export function takeNotice(env) {
252+ const path = join(dataDir(env), "notice.json");
253+ const notice = readJson(path, {});
254+ if (!notice.problem || notice.shown || !MESSAGES[notice.problem]) return null;
255+ write(path, JSON.stringify({ ...notice, shown: true }));
256+ return MESSAGES[notice.problem];
257+}
258+
259+/** Whether a working copy's remotes are on g1t (g1t.sh, or G1T_HOST's host). */
260+export function isG1tRemote(urls, env = process.env) {
261+ if (env.G1T_REPO) return true;
262+ const hosts = ["g1t.sh", env.G1T_HOST].filter(Boolean).map((h) => h.toLowerCase());
263+ return urls.some((url) => hosts.some((host) => url.toLowerCase().includes(host)));
264+}
265+
266+// --- the background half -----------------------------------------------
267+
268+/** Hands a job to a detached copy of this script and returns at once. */
269+function background(job, env = process.env) {
270+ const payload = Buffer.from(JSON.stringify(job)).toString("base64url");
271+ try {
272+ const child = spawn(process.execPath, [SELF, "--worker", payload], {
273+ cwd: job.cwd,
274+ detached: true,
275+ stdio: "ignore",
276+ windowsHide: true,
277+ env,
278+ });
279+ child.on("error", () => {});
280+ child.unref();
281+ } catch {}
282+}
283+
284+/** Starts the session if it needs to, then runs the job's steps in order. */
285+export function work(job, env = process.env) {
286+ const bin = findCli(env);
287+ if (!bin) return setProblem(env, "missing");
288+ const files = sessionFiles(job.sid, env);
289+ if (job.issue) write(files.issue, String(job.issue));
290+ const opts = { cwd: job.cwd, timeout: 20000, env: { ...env } };
291+ let id = readText(files.id);
292+ if (job.start || !id) {
293+ const issue = job.issue ?? readText(files.issue);
294+ const args = ["session", "start", "--harness", "claude_code", "--harness-session-id", job.sid];
295+ if (issue) args.push("--issue", String(issue));
296+ args.push("--json");
297+ const out = cli(bin, args, opts);
298+ if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
299+ if (out.status !== 0) return;
300+ setProblem(env, null);
301+ try {
302+ id = JSON.parse(out.stdout).id;
303+ } catch {
304+ id = /\bses_[A-Za-z0-9]+/.exec(out.stdout ?? "")?.[0] ?? null;
305+ }
306+ if (!id) return;
307+ write(files.id, id);
308+ }
309+ opts.env.G1T_SESSION = id;
310+ for (const step of job.steps) {
311+ if (step.args) {
312+ const out = cli(bin, step.args, opts);
313+ if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
314+ } else if (step.headCommit) {
315+ // Only once the push reached the remote: HEAD is on a remote branch.
316+ const head = git(job.cwd, ["rev-parse", "HEAD"]);
317+ const remote = git(job.cwd, ["branch", "-r", "--contains", "HEAD"]);
318+ if (head && remote) cli(bin, ["api", "update_session", "-f", `id=${id}`, "-f", `head_commit=${head}`, "--silent"], opts);
319+ }
320+ }
321+ if (job.end) for (const path of [files.marker, files.id, files.issue]) rmSync(path, { force: true });
322+}
323+
324+// --- the hooks ---------------------------------------------------------
325+
326+/**
327+ * SessionStart: marks the session as one in a g1t working copy, and
328+ * returns what to print: context for Claude, and a notice for you, once.
329+ * Null outside a g1t working copy, where nothing is recorded.
330+ */
331+export function sessionStart(event, env = process.env, bin = findCli(env)) {
332+ const cwd = event.cwd || process.cwd();
333+ const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
334+ if (!gitDir) return null;
335+ // The branch, from HEAD itself: also on a branch with no commits yet.
336+ const branch = /^ref: refs\/heads\/(.+)$/m.exec(readText(join(gitDir, "HEAD")) ?? "")?.[1] ?? null;
337+ const urls = (git(cwd, ["config", "--get-regexp", "^remote\\..*\\.url$"]) ?? "").split(/\r?\n/);
338+ if (!isG1tRemote(urls, env)) return null;
339+
340+ const files = sessionFiles(event.session_id, env);
341+ prune(files.dir);
342+ write(files.marker, JSON.stringify({ cwd, git_dir: gitDir }));
343+
344+ const output = {};
345+ if (!bin) setProblem(env, "missing");
346+ const notice = takeNotice(env);
347+ if (notice) output.systemMessage = notice;
348+ if (!bin) return output;
349+
350+ if (env.CLAUDE_ENV_FILE) {
351+ // Claude's own `g1t` commands then act in this session, not in another
352+ // one in the same working copy. Read as each command runs, since the
353+ // session's id arrives after this hook has returned.
354+ const p = files.id.replaceAll("\\", "/");
355+ try {
356+ writeFileSync(env.CLAUDE_ENV_FILE, `[ -f "${p}" ] && export G1T_SESSION="$(cat "${p}")"\n`, { flag: "a" });
357+ } catch {}
358+ }
359+
360+ const remembered = Number(readText(files.issue)) || null;
361+ const issue = remembered ?? issueFromBranch(branch);
362+ let context;
363+ if (issue) {
364+ const where = remembered ? "this session's claim" : `the branch ${branch}`;
365+ const out = cli(bin, ["work", "context", String(issue)], { cwd, timeout: CONTEXT_WAIT_MS, env });
366+ if (out.status === 0 && out.stdout.trim()) {
367+ context = `g1t: this session is working on issue #${issue} (from ${where}). Its work context on g1t:\n\n${clip(out.stdout.trim(), CONTEXT_LIMIT)}\n\nIf it is not claimed by this session yet, claim it with \`g1t work claim ${issue}\` before you start.`;
368+ } else if (out.status === EXIT_AUTH) {
369+ setProblem(env, "signed_out");
370+ const now = takeNotice(env);
371+ if (now) output.systemMessage = now;
372+ } else {
373+ context = `g1t: ${where} points at issue #${issue}. Load what is known about it with \`g1t work context ${issue}\`, and claim it with \`g1t work claim ${issue}\` before you start.`;
374+ }
375+ } else {
376+ context =
377+ "g1t: this is a g1t repository. Before starting on an issue, load it with `g1t work context N` and claim it with `g1t work claim N`, so the people and agents also working here know.";
378+ }
379+ if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context };
380+ return output;
381+}
382+
383+/** Runs one hook: what it prints, and the job it hands to the background. */
384+export function hook(event, env = process.env) {
385+ const job = plan(event, env);
386+ if (!job) return { output: null, job: null };
387+ if (event.hook_event_name === "SessionStart") {
388+ const bin = findCli(env);
389+ const output = sessionStart(event, env, bin);
390+ if (!output) return { output: null, job: null };
391+ return { output: Object.keys(output).length ? output : null, job: bin ? job : null };
392+ }
393+ // Only sessions SessionStart found in a g1t working copy are recorded.
394+ if (!existsSync(sessionFiles(event.session_id, env).marker) || !findCli(env)) return { output: null, job: null };
395+ const notice = event.hook_event_name === "UserPromptSubmit" ? takeNotice(env) : null;
396+ return { output: notice ? { systemMessage: notice } : null, job };
397+}
398+
399+async function main() {
400+ if (process.argv[2] === "--worker") {
401+ work(JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8")));
402+ return;
403+ }
404+ let raw = "";
405+ for await (const chunk of process.stdin) raw += chunk;
406+ const { output, job } = hook(JSON.parse(raw || "{}"));
407+ if (output) process.stdout.write(JSON.stringify(output));
408+ if (job) background(job);
409+}
410+
411+if (process.argv[1] && resolve(process.argv[1]) === SELF) {
412+ main()
413+ .catch(() => {})
414+ .finally(() => process.exit(0));
415+}
+31−0
1+{
2+ "description": "Records the session on g1t through the g1t CLI: prompts, tool names and what they were called with (never what they returned), pushes, and when it starts and ends. Gives Claude the current issue's work context at the start.",
3+ "hooks": {
4+ "SessionStart": [
5+ {
6+ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/g1t.mjs\"", "timeout": 5 }]
7+ }
8+ ],
9+ "UserPromptSubmit": [
10+ {
11+ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/g1t.mjs\"", "timeout": 5 }]
12+ }
13+ ],
14+ "PostToolUse": [
15+ {
16+ "matcher": "*",
17+ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/g1t.mjs\"", "timeout": 5 }]
18+ }
19+ ],
20+ "Stop": [
21+ {
22+ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/g1t.mjs\"", "timeout": 5 }]
23+ }
24+ ],
25+ "SessionEnd": [
26+ {
27+ "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/g1t.mjs\"", "timeout": 5 }]
28+ }
29+ ]
30+ }
31+}
+43−0
1+---
2+name: coordinate
3+description: Stay coordinated with other agents and people while working on a g1t issue. Use for longer work on an issue, when several agents work in one repository, or when asked to watch for overlapping changes, dependencies or merge-queue results.
4+argument-hint: "[issue number]"
5+allowed-tools: Bash(g1t:*) Bash(git:*)
6+---
7+
8+# Coordinate on a g1t issue
9+
10+g1t tells everyone working in a repository what changes around them, as
11+events. Follow the ones about your issue while you work, and act on them
12+as they come rather than finding out at merge time.
13+
14+Issue: $ARGUMENTS (if empty, use the issue this session claimed).
15+
16+1. **Follow its events in the background.** Start
17+ `g1t work events --follow --issue N` as a background command, and read
18+ its output between steps. Each line is one event. Add `--json` for one
19+ JSON object per line.
20+2. **React to what arrives:**
21+
22+ | Event | What to do |
23+ | --- | --- |
24+ | `work.claimed` by someone else | Tell the person who asked you. Agree who goes on; hand off if it is not you. |
25+ | `work.handoff_requested` to you | `g1t work context N`, read the note, then claim it. |
26+ | `overlap.detected` | Another open pull request changes the same files. `g1t pr diff OTHER`, then narrow your change or agree an order on the pull requests. |
27+ | `dependency.changed` | Something this issue waits on changed. If `cleared`, the blockers are gone: pull the default branch and go on. If one was added, stop and say so. |
28+ | `integration.failed` naming your pull request | Use the `fix-integration` skill. |
29+ | `integration.passed` | Nothing to do; the change is landing. |
30+ | `session.ended`, `work.claim_expired` | A collaborator stopped. If their work blocks yours, look at what they left with `g1t work context N`. |
31+
32+3. **Keep your own state true.** The session's heartbeat renews your
33+ claim while you work. If you change course, log why with
34+ `g1t session log --kind note "TEXT"`.
35+4. **Hand off cleanly.** When you stop before the issue is done:
36+ `g1t work handoff N --note "what is done, what is left, what to watch out for"`
37+ (add `--to LOGIN` for someone in particular). When it is done, the
38+ claim ends with the merge; `g1t work release N` lets go of it earlier.
39+5. **Stop following** when you hand off or the pull request merges: stop
40+ the background command.
41+
42+`g1t work list` shows every active claim and session in the repository,
43+for when you need the whole picture.
+47−0
1+---
2+name: fix-integration
3+description: Fix a g1t pull request that failed when the merge queue tested it together with other pull requests (an integration.failed event). Use when g1t reports an integration failure, or a queued pull request was removed from the merge queue because a combined check failed.
4+argument-hint: "[pull request number]"
5+allowed-tools: Bash(g1t:*) Bash(git:*)
6+---
7+
8+# Fix an integration failure
9+
10+The merge queue tests pull requests together, in queue order. A pull
11+request can pass on its own and still fail once another one is merged
12+with it. g1t then records an `integration.failed` event naming the pull
13+request it holds responsible, the combination it tested (the pull requests
14+merged together, in order), the failing check and its link, whether it
15+bisected the combination to find the culprit, and the suspects when it
16+could not tell.
17+
18+Pull request: $ARGUMENTS (if empty, use the current branch's pull request).
19+
20+1. **Read what failed.** `g1t work events --type integration.failed --json`
21+ for the event (narrow it with `--issue N`), and `g1t work context N` for
22+ the issue, which shows the last integration failure, the pull requests
23+ on it and the decisions so far.
24+2. **Open the failing check's log** from the event's check link, or with
25+ `g1t run logs --failed` on that run. Find the first real error.
26+3. **Read the other pull requests in the combination.**
27+ `g1t pr diff OTHER` for each one tested with yours, and look for what
28+ meets your change: the same function changed two ways, a renamed or
29+ removed name you still use, a migration, a fixture or a test that
30+ assumes the old behaviour.
31+4. **Reproduce it locally.** Merge the default branch and the other pull
32+ requests' branches into a scratch branch in the same order, and run the
33+ failing check's command.
34+5. **Fix it on your branch.** Change only what the failure needs. If the
35+ right fix belongs in the other pull request, say so on it
36+ (`g1t pr comment OTHER`) instead of working around it, and tell the
37+ person who asked you.
38+6. **Push, then queue again.** Push the fix, wait for `g1t pr checks
39+ --watch` to pass, and merge again with `g1t pr merge`, which puts it
40+ back in the queue.
41+7. **Leave a record.** `g1t session log --kind note "Integration failure
42+ with #OTHER: CAUSE. Fixed by CHANGE."` so the next person can see why
43+ the change looks the way it does.
44+
45+If the event says the failure was not bisected and lists several
46+suspects, check that yours really is one before changing anything: run the
47+check on your branch with only the default branch merged in.
+53−0
1+---
2+name: work-on-issue
3+description: Work on a g1t issue from start to merge. Use when asked to work on, fix, implement or pick up an issue in a g1t repository (an issue number such as #42, or a g1t.sh issue link).
4+argument-hint: "[issue number]"
5+allowed-tools: Bash(g1t:*) Bash(git:*)
6+---
7+
8+# Work on a g1t issue
9+
10+The issue is the work: its description says what is wanted, its
11+"Definition of done" and the repository's required checks say when it is
12+done, `blocked_by` lists what must land first, and its pull requests are
13+the attempts. Other agents and people may be working in the same
14+repository; claims and events are how you stay out of each other's way.
15+Every `g1t` command takes `--json` when you need fields rather than text.
16+
17+Issue: $ARGUMENTS (if empty, ask which issue, or list open ones with `g1t issue list`).
18+
19+1. **Load the context.** Run `g1t work context N`. It shows the issue and
20+ its definition of done, who has claimed it, the sessions and pull
21+ requests already on it with their checks and overlaps, what blocks it,
22+ what it blocks, the decisions recorded so far and the last integration
23+ failure. Read all of it before you change anything.
24+2. **Check it is free to start.**
25+ - If it is blocked by issues that are still open, say so and stop
26+ unless you are asked to go on anyway.
27+ - If someone else holds an active claim, tell the person who asked you,
28+ and go on only if they say to. Two claims are allowed; they are a
29+ warning, not a lock.
30+ - If a pull request for it is already open, prefer continuing that one
31+ (`g1t pr checkout NUMBER`) over starting another.
32+3. **Claim it.** `g1t work claim N --note "what you are about to do"`. The
33+ claim belongs to this session and is renewed while the session is
34+ active.
35+4. **Branch.** From an up-to-date default branch, make a branch whose name
36+ starts with the issue number, such as `42-short-description`, so the
37+ session and the next one know which issue it is for.
38+5. **Open a draft pull request early**, as soon as there is a first commit:
39+ `g1t pr create --issue N --draft --fill`. Others then see your changes,
40+ and g1t tells you when they overlap with someone else's.
41+6. **Do the work.** Commit in small steps and push often. Run the same
42+ tests and linters the repository's workflows run before each push. Log
43+ a decision worth keeping with `g1t session log --kind note "TEXT"`.
44+7. **Watch the checks.** `g1t pr checks --watch --fail-fast`. When one
45+ fails, `g1t run logs --failed` says why; fix it and push again.
46+8. **Finish.** When the definition of done is met and the checks pass,
47+ `g1t pr ready --summary "what changed and why"`, then `g1t pr merge`
48+ when you are allowed to merge. With the merge queue on, merging adds the
49+ pull request to the queue; if the queue reports an integration failure,
50+ use the `fix-integration` skill.
51+9. **Let go.** If you stop before it is done, hand it off rather than
52+ leaving a claim to expire: `g1t work handoff N --note "where it stands,
53+ what is left, what to watch out for"`.
+11−0
1+#!/usr/bin/env node
2+// A stand-in for the g1t CLI in the hook tests: writes each call to
3+// FAKE_G1T_LOG as a JSON line and answers as FAKE_G1T_* say.
4+import { appendFileSync } from "node:fs";
5+
6+const args = process.argv.slice(2);
7+appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null })}\n`);
8+const exit = Number(process.env.FAKE_G1T_EXIT ?? 0);
9+if (exit) process.exit(exit);
10+if (args[0] === "session" && args[1] === "start") process.stdout.write(JSON.stringify({ id: "ses_fake01", state: "active" }));
11+if (args[0] === "work" && args[1] === "context") process.stdout.write(`#${args[2]} The issue's title\nDefinition of done: it works.\n`);
+269−0
1+// Tests for the plugin's hooks: node --test plugins/claude-code/test/
2+import assert from "node:assert/strict";
3+import { spawnSync } from "node:child_process";
4+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
5+import { tmpdir } from "node:os";
6+import { join } from "node:path";
7+import { afterEach, beforeEach, describe, test } from "node:test";
8+import { fileURLToPath } from "node:url";
9+
10+import {
11+ PROMPT_LIMIT,
12+ hook,
13+ isG1tRemote,
14+ isGitPush,
15+ issueFromBranch,
16+ issueFromCommand,
17+ plan,
18+ redact,
19+ sessionFiles,
20+ summarizeTool,
21+ work,
22+} from "../hooks/g1t.mjs";
23+
24+const HERE = fileURLToPath(new URL(".", import.meta.url));
25+const SCRIPT = join(HERE, "..", "hooks", "g1t.mjs");
26+const FAKE = join(HERE, "fake-g1t.mjs");
27+
28+const base = { session_id: "abc-123", cwd: "/work/repo", transcript_path: "/t.jsonl", permission_mode: "default" };
29+
30+describe("plan", () => {
31+ test("SessionStart starts the session", () => {
32+ const job = plan({ ...base, hook_event_name: "SessionStart", source: "startup" }, {});
33+ assert.equal(job.start, true);
34+ assert.deepEqual(job.steps, []);
35+ });
36+
37+ test("UserPromptSubmit logs the prompt, cut to the limit", () => {
38+ const prompt = "x".repeat(PROMPT_LIMIT + 500);
39+ const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt }, {});
40+ const args = job.steps[0].args;
41+ assert.deepEqual(args.slice(0, 5), ["session", "log", "--kind", "prompt", "--"]);
42+ assert.equal(args[5].length, PROMPT_LIMIT);
43+ assert.ok(args[5].endsWith("…"));
44+ });
45+
46+ test("G1T_RECORD_PROMPTS=0 keeps prompts back, and G1T_RECORD=0 everything", () => {
47+ assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, { G1T_RECORD_PROMPTS: "0" }), null);
48+ assert.ok(plan({ ...base, hook_event_name: "Stop" }, { G1T_RECORD_PROMPTS: "0" }));
49+ for (const hook_event_name of ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"]) {
50+ assert.equal(plan({ ...base, hook_event_name, prompt: "hi", tool_name: "Bash" }, { G1T_RECORD: "0" }), null);
51+ }
52+ });
53+
54+ test("PostToolUse sends the tool and its input's summary, never its response", () => {
55+ const event = {
56+ ...base,
57+ hook_event_name: "PostToolUse",
58+ tool_name: "Bash",
59+ tool_input: { command: "npm test", description: "Run the tests" },
60+ tool_response: { stdout: "SECRET OUTPUT 1234", stderr: "", interrupted: false },
61+ tool_use_id: "toolu_1",
62+ };
63+ const job = plan(event, {});
64+ assert.deepEqual(job.steps, [{ args: ["session", "log", "--kind", "tool_call", "--", "Bash: npm test"] }]);
65+ assert.ok(!JSON.stringify(job).includes("SECRET OUTPUT"));
66+ });
67+
68+ test("PostToolUse after a git push updates the head commit", () => {
69+ const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push -u origin 42-fix" } }, {});
70+ assert.deepEqual(job.steps[1], { headCommit: true });
71+ const other = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git status" } }, {});
72+ assert.equal(other.steps.length, 1);
73+ });
74+
75+ test("PostToolUse remembers the issue a command claims", () => {
76+ const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "g1t work claim 42 --note x" } }, {});
77+ assert.equal(job.issue, 42);
78+ });
79+
80+ test("Stop sends a heartbeat and SessionEnd ends the session with its reason", () => {
81+ assert.deepEqual(plan({ ...base, hook_event_name: "Stop", stop_hook_active: false }, {}).steps, [{ args: ["session", "heartbeat"] }]);
82+ const end = plan({ ...base, hook_event_name: "SessionEnd", reason: "prompt_input_exit" }, {});
83+ assert.deepEqual(end.steps, [{ args: ["session", "end", "--reason", "prompt_input_exit"] }]);
84+ assert.equal(end.end, true);
85+ });
86+
87+ test("events without a session, and other events, do nothing", () => {
88+ assert.equal(plan({ hook_event_name: "Stop" }, {}), null);
89+ assert.equal(plan({ ...base, hook_event_name: "PreToolUse", tool_name: "Bash" }, {}), null);
90+ });
91+});
92+
93+describe("summaries", () => {
94+ test("say what a tool was called with, in one short line", () => {
95+ assert.equal(summarizeTool("Edit", { file_path: "/work/repo/src/a.ts", old_string: "x", new_string: "y" }, "/work/repo"), "Edit: src/a.ts");
96+ assert.equal(summarizeTool("Grep", { pattern: "TODO", path: "src" }, "/w"), "Grep: TODO in src");
97+ assert.equal(summarizeTool("WebFetch", { url: "https://example.com", prompt: "p" }), "WebFetch: https://example.com");
98+ assert.equal(summarizeTool("Bash", { command: "echo one\necho two" }), "Bash: echo one …");
99+ assert.equal(summarizeTool("mcp__g1t__issue", { action: "get", number: 4 }), "mcp__g1t__issue: get");
100+ assert.ok(summarizeTool("Bash", { command: "y".repeat(1000) }).length <= 200);
101+ });
102+
103+ test("hide what looks like a secret", () => {
104+ const line = redact('curl -H "Authorization: Bearer g1t_abcdefghijklmnop" https://u:pw@x.com GITHUB_TOKEN=ghp_123456789012 --token abc');
105+ assert.ok(!line.includes("abcdefghijklmnop"));
106+ assert.ok(!line.includes("ghp_123456789012"));
107+ assert.ok(!line.includes("u:pw@"));
108+ assert.ok(!line.includes("--token abc"));
109+ });
110+
111+ test("find the issue in a branch name", () => {
112+ assert.equal(issueFromBranch("42-fix-login"), 42);
113+ assert.equal(issueFromBranch("issue-7"), 7);
114+ assert.equal(issueFromBranch("fix/123-thing"), 123);
115+ assert.equal(issueFromBranch("main"), null);
116+ assert.equal(issueFromBranch("release/1.2"), null);
117+ assert.equal(issueFromBranch("HEAD"), null);
118+ assert.equal(issueFromCommand("g1t session attach --issue 9"), 9);
119+ assert.equal(issueFromCommand("g1t pr create --issue=12 --draft"), 12);
120+ assert.equal(isGitPush("cd x && git -C repo push"), true);
121+ assert.equal(isGitPush("git pushd"), false);
122+ assert.equal(isG1tRemote(["remote.origin.url https://g1t.sh/a/b.git"], {}), true);
123+ assert.equal(isG1tRemote(["remote.origin.url https://example.com/a/b.git"], {}), false);
124+ assert.equal(isG1tRemote(["remote.origin.url https://git.acme.dev/a/b.git"], { G1T_HOST: "git.acme.dev" }), true);
125+ });
126+});
127+
128+describe("the plugin's files", () => {
129+ const root = join(HERE, "..", "..", "..");
130+ test("parse as JSON", () => {
131+ for (const file of [".claude-plugin/marketplace.json", "plugins/claude-code/.claude-plugin/plugin.json", "plugins/claude-code/.mcp.json", "plugins/claude-code/hooks/hooks.json"]) {
132+ assert.doesNotThrow(() => JSON.parse(readFileSync(join(root, file), "utf8")), file);
133+ }
134+ });
135+
136+ test("the AGENTS.md snippet is the one in the docs", () => {
137+ const snippet = readFileSync(join(root, "plugins/other-harnesses/AGENTS.snippet.md"), "utf8").replaceAll("\r\n", "\n");
138+ const guide = readFileSync(join(root, "apps/docs/src/content/docs/guides/claude-code-plugin.md"), "utf8").replaceAll("\r\n", "\n");
139+ const block = /```md title="AGENTS.md"\n([\s\S]*?)```/.exec(guide)?.[1];
140+ assert.equal(block, snippet);
141+ });
142+});
143+
144+describe("with a working copy", () => {
145+ let dir, env, log;
146+
147+ const git = (...args) => spawnSync("git", args, { cwd: dir, encoding: "utf8" });
148+ const calls = () =>
149+ existsSync(log)
150+ ? readFileSync(log, "utf8")
151+ .trim()
152+ .split("\n")
153+ .filter(Boolean)
154+ .map((l) => JSON.parse(l))
155+ : [];
156+
157+ beforeEach(() => {
158+ dir = mkdtempSync(join(tmpdir(), "g1t-hook-"));
159+ log = join(dir, "calls.jsonl");
160+ git("init", "-q", "-b", "42-fix-login");
161+ git("remote", "add", "origin", "https://g1t.sh/acme/app.git");
162+ env = { PATH: process.env.PATH, G1T_BIN: FAKE, FAKE_G1T_LOG: log, CLAUDE_PLUGIN_DATA: join(dir, ".data") };
163+ });
164+ afterEach(() => rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }));
165+
166+ test("SessionStart gives Claude the branch's issue's work context", () => {
167+ const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env);
168+ const context = output.hookSpecificOutput.additionalContext;
169+ assert.equal(output.hookSpecificOutput.hookEventName, "SessionStart");
170+ assert.match(context, /issue #42/);
171+ assert.match(context, /Definition of done: it works/);
172+ assert.deepEqual(calls()[0].args, ["work", "context", "42"]);
173+ assert.equal(job.start, true);
174+ });
175+
176+ test("the background half starts the session once, then logs into it", () => {
177+ work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
178+ const { job } = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env);
179+ // No SessionStart marked this session as one in a g1t working copy.
180+ assert.equal(job, null);
181+ work(plan({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env), env);
182+ const [start, logged] = calls();
183+ assert.deepEqual(start.args, ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123", "--json"]);
184+ assert.deepEqual(logged, { args: ["session", "log", "--kind", "prompt", "--", "fix the login"], session: "ses_fake01" });
185+ assert.equal(readFileSync(sessionFiles(base.session_id, env).id, "utf8"), "ses_fake01");
186+ });
187+
188+ test("hooks after SessionStart record; SessionEnd forgets the session", () => {
189+ hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env);
190+ const files = sessionFiles(base.session_id, env);
191+ assert.ok(existsSync(files.marker));
192+ const { job } = hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env);
193+ work(job, env);
194+ work(plan({ ...base, cwd: dir, hook_event_name: "SessionEnd", reason: "clear" }, env), env);
195+ const args = calls().map((c) => c.args.join(" "));
196+ assert.ok(args.includes("session heartbeat"));
197+ assert.ok(args.includes("session end --reason clear"));
198+ assert.ok(!existsSync(files.marker) && !existsSync(files.id));
199+ });
200+
201+ test("a push that reached the remote updates the head commit", () => {
202+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "one");
203+ git("update-ref", "refs/remotes/origin/42-fix-login", "HEAD");
204+ const head = git("rev-parse", "HEAD").stdout.trim();
205+ work(plan({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push" } }, env), env);
206+ const update = calls().find((c) => c.args[0] === "api");
207+ assert.deepEqual(update.args, ["api", "update_session", "-f", "id=ses_fake01", "-f", `head_commit=${head}`, "--silent"]);
208+ });
209+
210+ test("a missing CLI records nothing and says so once", () => {
211+ const missing = { ...env, G1T_BIN: join(dir, "no-such-g1t") };
212+ const first = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing);
213+ assert.match(first.output.systemMessage, /not installed/);
214+ assert.equal(first.job, null);
215+ const second = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing);
216+ assert.equal(second.output, null);
217+ assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, missing).job, null);
218+ assert.deepEqual(calls(), []);
219+ });
220+
221+ test("a signed-out CLI is said once, at the next prompt", () => {
222+ hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, CLAUDE_PLUGIN_DATA: env.CLAUDE_PLUGIN_DATA });
223+ work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, { ...env, FAKE_G1T_EXIT: "4" });
224+ const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env);
225+ assert.match(first.output.systemMessage, /not signed in/);
226+ const second = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env);
227+ assert.equal(second.output, null);
228+ });
229+
230+ test("outside a g1t repository nothing happens", () => {
231+ git("remote", "set-url", "origin", "https://example.com/acme/app.git");
232+ const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
233+ assert.equal(output, null);
234+ assert.equal(job, null);
235+ assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env).job, null);
236+ assert.deepEqual(calls(), []);
237+ });
238+
239+ test("the script answers SessionStart on stdin quickly, as JSON", async () => {
240+ const started = Date.now();
241+ const out = spawnSync(process.execPath, [SCRIPT], {
242+ input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }),
243+ encoding: "utf8",
244+ env: { ...process.env, ...env },
245+ });
246+ const took = Date.now() - started;
247+ assert.equal(out.status, 0);
248+ assert.match(JSON.parse(out.stdout).hookSpecificOutput.additionalContext, /issue #42/);
249+ assert.ok(took < 3000, `took ${took} ms`);
250+ // The detached half starts the session after the hook has returned.
251+ const id = sessionFiles(base.session_id, env).id;
252+ for (let i = 0; i < 100 && !existsSync(id); i++) await new Promise((done) => setTimeout(done, 50));
253+ assert.equal(readFileSync(id, "utf8"), "ses_fake01");
254+ await new Promise((done) => setTimeout(done, 200));
255+ });
256+
257+ test("the script prints nothing for a prompt, and never fails on bad input", () => {
258+ const prompt = spawnSync(process.execPath, [SCRIPT], {
259+ input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hello" }),
260+ encoding: "utf8",
261+ env: { ...process.env, ...env },
262+ });
263+ assert.equal(prompt.status, 0);
264+ assert.equal(prompt.stdout, "");
265+ const bad = spawnSync(process.execPath, [SCRIPT], { input: "not json", encoding: "utf8", env: { ...process.env, ...env } });
266+ assert.equal(bad.status, 0);
267+ assert.equal(bad.stdout, "");
268+ });
269+});
+27−0
1+## Working on g1t
2+
3+This repository is on g1t. Other agents and people may be working in it at
4+the same time; the `g1t` CLI is how you see them and how they see you.
5+
6+- When you start, record a session: `g1t session start --harness codex`
7+ (or `cursor`, `copilot`, `other`: the harness you are). The CLI remembers
8+ it for this working copy. Run `g1t session heartbeat` every few minutes
9+ while you work, and `g1t session end` when you stop.
10+- Before working on an issue, load it with `g1t work context N`: its
11+ definition of done, who has claimed it, its pull requests, checks and
12+ overlaps, and what blocks it. Then claim it with
13+ `g1t work claim N --note "what you are about to do"`. If someone else
14+ holds a claim, say so before going on.
15+- Work on a branch whose name starts with the issue number
16+ (`42-short-description`), and open a draft pull request early:
17+ `g1t pr create --issue N --draft --fill`.
18+- Log decisions worth keeping with `g1t session log --kind note "TEXT"`.
19+ Never log secrets or command output.
20+- Run the repository's tests before each push, and watch the checks with
21+ `g1t pr checks --watch --fail-fast` (and `g1t run logs --failed` for why
22+ one failed). When the merge queue reports an integration failure, read
23+ it with `g1t work events --type integration.failed --issue N`, diff the
24+ other pull requests in the combination (`g1t pr diff OTHER`), fix, push
25+ and merge again.
26+- If you stop before the issue is done, hand it off:
27+ `g1t work handoff N --note "what is done, what is left"`.