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.
16 files+974−970/16 viewed
| 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 | + | } |
| 95 | 95 | run: npm run test:ops | |
| 96 | 96 | - name: The manifest matches every wrangler.jsonc | |
| 97 | 97 | 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 | |
| 98 | 100 | ||
| 99 | 101 | build: | |
| 100 | 102 | name: Build |
| 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 | − | } |
| 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 | + | } |
| 1 | + | { | |
| 2 | + | "mcpServers": { | |
| 3 | + | "g1t": { | |
| 4 | + | "type": "http", | |
| 5 | + | "url": "https://mcp.g1t.sh" | |
| 6 | + | } | |
| 7 | + | } | |
| 8 | + | } |
| 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. |
| 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. |
| 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. |
| 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 | + | } |
| 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 | + | } |
| 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. |
| 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. |
| 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"`. |
| 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`); |
| 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 | + | }); |
| 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"`. |