Skip to content

Commit

g1t has a plugin for Claude Code, in its own marketplace at the repository's root (claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git, then claude plugin install g1t@g1t). Its hooks record each session on g1t through the g1t CLI in a working copy of a g1t repository: SessionStart starts the session (g1t session start --harness claude_code --harness-session-id, idempotent on Claude's session id) and gives Claude the work context of the issue the session claimed or the branch names (g1t work context N, waited for under a second, else a line saying how to load it), or a line on how to claim one; UserPromptSubmit logs the prompt's first 2,000 characters; PostToolUse logs the tool's name and one line on what it was called with, with anything that looks like a secret hidden, and never what it returned, and after a git push that reached the remote sets the session's head commit; Stop sends a heartbeat and SessionEnd ends the session with Claude's reason. The hooks are one Node script with no dependencies that returns at once and runs the CLI in a detached process; when g1t is missing or signed out it records nothing and says so once, and it installs nothing. G1T_RECORD_PROMPTS=0 keeps prompts back and G1T_RECORD=0 records nothing. The plugin also connects g1t's MCP server, has the skills work-on-issue, fix-integration and coordinate and the commands /g1t:claim, /g1t:status and /g1t:handoff, and plugins/other-harnesses has the AGENTS.md lines that do the same with the CLI for Codex and other agents. node --test plugins/claude-code/test/hooks.test.mjs checks the hooks against a stand-in CLI, and CI runs it.

syntaqxcommitted Parent85c083aBrowse files
16 files+974−970/16 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
+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−}
+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"`.