Skip to content

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

g1t.sh
887 linesCodeBlameRaw
1#!/usr/bin/env node
2// g1t's Claude Code hooks. Claude Code runs this file for SessionStart,
3// UserPromptSubmit, PreToolUse, PostToolUse, SubagentStart, SubagentStop,
4// Stop and SessionEnd, with the event as JSON on stdin. In a working copy of
5// a g1t repository it records the session on g1t through the `g1t` CLI:
6//
7// SessionStart g1t session start --harness claude_code --harness-session-id ID
8// and, when the branch, G1T_ISSUE or the session names an
9// issue, g1t work context N, given to Claude as context
10// UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters)
11// and, for a plugin's skill typed as /plugin:name,
12// g1t session log --kind skill NAME; at the session's
13// first prompt that is not a slash command, its intent
14// as the title, unless it has one: g1t session heartbeat
15// --json, then g1t session attach --title INTENT
16// PreToolUse Agent: the task it is given, kept until its subagent starts.
17// Inside a subagent, its own g1t session for the g1t
18// commands (Bash or PowerShell) and g1t MCP claims it makes.
19// PostToolUse g1t session log --kind tool_call "Tool: what it was called with"
20// (a Skill: g1t session log --kind skill NAME), and
21// g1t session touch for the files it wrote, the commits
22// it made, the branches it pushed and the issues it claimed;
23// after a git push that reached the remote,
24// g1t api update_session -f id=SESSION -f head_commit=SHA;
25// after an Agent call, its subagent's task and, once it
26// finished, its final message
27// SubagentStart g1t session start --parent SESSION --agent-type TYPE …:
28// each subagent is a session of its own, under this one
29// SubagentStop g1t session end --result "its final message" for it
30// Stop g1t session heartbeat
31// SessionEnd g1t session end --reason REASON
32//
33// What a tool returned (tool_response) is never sent, with one exception
34// that is not a tool's output: an Agent call's answer, which is its
35// subagent's final message (as SubagentStop gives it) and its id. Prompts
36// are not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0.
37//
38// Every hook returns at once: the CLI calls run in a detached process of
39// their own, so a slow network never holds Claude up. If `g1t` is not
40// installed, not signed in or older than MIN_CLI (or refuses a command,
41// as an old CLI does), nothing is recorded and Claude Code says so once.
42// Nothing is ever installed. No dependencies; Node 18 or later, and on an
43// older Node it says so once and does nothing.
44//
45// Every CLI call is in test/cli-contract.json, which the tests hold this
46// file and the CLI's own parser (crates/g1t) to.
47
48import { spawn, spawnSync } from "node:child_process";
49import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
50import { homedir } from "node:os";
51import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
52import { fileURLToPath } from "node:url";
53
54/** The most of a prompt that is sent. */
55export const PROMPT_LIMIT = 2000;
56/** The most of a tool call's summary that is sent. */
57export const SUMMARY_LIMIT = 200;
58/** The most of an issue's work context given to Claude. */
59export const CONTEXT_LIMIT = 8000;
60/** The most of a prompt's intent that titles a session. */
61export const TITLE_LIMIT = 80;
62/** The most of a subagent's task (its description) that is sent. */
63export const TASK_LIMIT = 200;
64/** The most of a subagent's prompt that is sent. */
65export const AGENT_PROMPT_LIMIT = 4000;
66/** The most of a subagent's final message that is sent. */
67export const RESULT_LIMIT = 8000;
68/** How long SessionStart waits for an issue's work context. */
69const CONTEXT_WAIT_MS = 900;
70/** Exit code the g1t CLI gives when signing in is needed. */
71const EXIT_AUTH = 4;
72/** Exit code the g1t CLI gives for a command it does not take, as a CLI older than the plugin does. */
73const EXIT_USAGE = 2;
74/** The oldest g1t CLI that takes every command the hooks run (test/cli-contract.json's minimum_version). */
75export const MIN_CLI = "0.3.2";
76/** The oldest Node the hooks run on. */
77export const MIN_NODE = 18;
78/** How to update the CLI: its installer, run again. */
79const INSTALL = "`curl -fsSL https://g1t.sh/install | sh` (on Windows, `irm https://g1t.sh/install.ps1 | iex`)";
80/** The tools that run shell commands. */
81const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
82/** The tool that starts subagents (Task before Claude Code renamed it). */
83const AGENT_TOOLS = new Set(["Agent", "Task"]);
84/** Tools that write a file, and where they say which. */
85const WRITERS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
86
87const SELF = fileURLToPath(import.meta.url);
88
89// --- what to send ------------------------------------------------------
90
91export function clip(text, limit) {
92 const value = String(text ?? "");
93 return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
94}
95
96/** Hides what looks like a secret in a command line before it is sent. */
97export function redact(text) {
98 return String(text)
99 .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…")
100 .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…")
101 .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…")
102 .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…")
103 .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@");
104}
105
106/** A path as the repository names it: relative to its root, with forward slashes; null outside it. */
107export function repoPath(path, cwd) {
108 if (typeof path !== "string" || !path) return null;
109 if (!isAbsolute(path)) return path.replaceAll("\\", "/").replace(/^\.\//, "");
110 if (!cwd) return null;
111 const rel = relative(cwd, path);
112 if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
113 return rel.replaceAll("\\", "/");
114}
115
116/** What a subagent's shell command starts with to act as its session (see preToolUse), in Bash or PowerShell. */
117export function sessionPrefix(tool, session) {
118 return tool === "PowerShell" ? `$env:G1T_SESSION='${session}'; ` : `export G1T_SESSION=${session}; `;
119}
120
121const PREFIX = /^(?:export G1T_SESSION=[A-Za-z0-9_]+; |\$env:G1T_SESSION='[A-Za-z0-9_]+'; )/;
122
123/** One line on what a tool was called with: never what it returned. */
124export function summarizeTool(name, input, cwd) {
125 const i = input && typeof input === "object" ? input : {};
126 const path = (p) => {
127 if (typeof p !== "string") return "";
128 return repoPath(p, cwd) ?? p;
129 };
130 let what = "";
131 if (typeof i.command === "string") {
132 // Without the session the plugin gave a subagent's command.
133 const command = i.command.replace(PREFIX, "");
134 what = command.split("\n")[0] + (command.includes("\n") ? " …" : "");
135 }
136 else if (i.file_path || i.notebook_path) what = path(i.file_path ?? i.notebook_path);
137 else if (typeof i.pattern === "string") what = `${i.pattern}${i.path ? ` in ${path(i.path)}` : ""}`;
138 else if (typeof i.url === "string") what = i.url;
139 else if (typeof i.query === "string") what = i.query;
140 else if (typeof i.description === "string") what = i.description;
141 else if (typeof i.action === "string") what = i.action;
142 const line = what ? `${name}: ${redact(what)}` : String(name);
143 return clip(line, SUMMARY_LIMIT);
144}
145
146/** The issue a branch is for, when its name holds the number: 42-fix, issue-42, fix/42, g1t/42-x. */
147export function issueFromBranch(branch) {
148 if (!branch || branch === "HEAD") return null;
149 if (/^(release|releases|hotfix|v\d)/i.test(branch)) return null;
150 const match = /(?:^|[/_-])(?:issues?-?|#)?(\d{1,7})(?=$|[/_-])/i.exec(branch);
151 if (!match) return null;
152 const n = Number(match[1]);
153 return n > 0 ? n : null;
154}
155
156/** The issue a shell command claims or attaches the session to, if any. */
157export function issueFromCommand(command) {
158 if (typeof command !== "string") return null;
159 const patterns = [
160 /\bg1t\s+work\s+claim\s+#?(\d+)/,
161 /\bg1t\s+session\s+(?:start|attach)\b[^|;&\n]*--issue[= ]#?(\d+)/,
162 /\bg1t\s+pr\s+create\b[^|;&\n]*--issue[= ]#?(\d+)/,
163 ];
164 for (const pattern of patterns) {
165 const match = pattern.exec(command);
166 if (match) return Number(match[1]);
167 }
168 return null;
169}
170
171export const isGitPush = (command) =>
172 typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+push\b/.test(command);
173
174export const isGitCommit = (command) =>
175 typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+commit\b/.test(command);
176
177/** The branch a command makes: git checkout -b X, git switch -c X, git branch X. */
178export function branchFromCommand(command) {
179 if (typeof command !== "string") return null;
180 const match =
181 /\bgit\s+(?:checkout\s+-[bB]|switch\s+-[cC]|switch\s+--create)\s+([^\s;&|]+)/.exec(command) ??
182 /\bgit\s+branch\s+(?!-)([^\s;&|]+)/.exec(command);
183 return match ? match[1] : null;
184}
185
186/** A subagent's harness id: the parent's, `/agent/`, and its id without Claude Code's `agent-` prefix. */
187export function subagentHarnessId(parent, agentId) {
188 return `${String(parent).trim()}/agent/${String(agentId).trim().replace(/^agent-/, "")}`;
189}
190
191/** The text of an Agent call's answer: its subagent's final message. */
192export function agentResult(response) {
193 const content = response?.content;
194 if (typeof content === "string") return content;
195 if (!Array.isArray(content)) return "";
196 return content
197 .filter((block) => block && block.type === "text" && typeof block.text === "string")
198 .map((block) => block.text)
199 .join("\n")
200 .trim();
201}
202
203/**
204 * What a prompt is for, as a session's title: its first line or sentence,
205 * without code blocks or markdown, whitespace collapsed, clipped at
206 * TITLE_LIMIT on a word with "…". Null for a slash command (/clear,
207 * /g1t:status 4), a turn Claude Code starts itself, or nothing to say.
208 * apps/web/app/lib/coordination.ts reads an untitled session's first
209 * prompt the same way.
210 */
211export function intentTitle(prompt) {
212 let text = String(prompt ?? "").replace(/^\uFEFF/, "");
213 if (/^\s*\//.test(text) || /^\s*<[a-z][\w-]*>/i.test(text)) return null;
214 text = text
215 .replace(/```[\s\S]*?(```|$)/g, "\n")
216 .replace(/~~~[\s\S]*?(~~~|$)/g, "\n")
217 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
218 .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
219 .replace(/`([^`]*)`/g, "$1");
220 const line = text
221 .split(/\r?\n/)
222 .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").trim())
223 .find((l) => /[\p{L}\p{N}]/u.test(l));
224 if (!line) return null;
225 // Its first sentence: up to a stop followed by a space.
226 let intent = line.replace(/\s+/g, " ").split(/(?<=[.!?])\s+(?=\S)/)[0].trim();
227 if (intent.length > TITLE_LIMIT) {
228 const cut = intent.slice(0, TITLE_LIMIT - 1);
229 const space = cut.lastIndexOf(" ");
230 intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`;
231 } else {
232 intent = intent.replace(/[\s,;:.]+$/, "");
233 }
234 if (intent.length < 2) return null;
235 return intent[0].toLocaleUpperCase() + intent.slice(1);
236}
237
238/** The plugin skill a prompt runs when it is typed as one: `g1t:work-on-issue` for `/g1t:work-on-issue 42`. */
239export function typedSkill(prompt) {
240 const text = String(prompt ?? "").replace(/^/, "");
241 const match = /^\s*\/([A-Za-z0-9][\w.-]*:[A-Za-z0-9][\w.-]*)(?=\s|$)/.exec(text);
242 return match ? match[1] : null;
243}
244
245/**
246 * A skill's step: its name only. `skill` keys it (see hook), so a skill
247 * typed as /g1t:name, which Claude Code may then also run as a Skill tool
248 * call, is recorded once in the turn.
249 */
250function skillStep(name) {
251 return { args: ["session", "log", "--kind", "skill", "--", clip(name, 100)], skill: name.replace(/^.*:/, "") };
252}
253
254/** The g1t MCP tool a call is to, by name: `issue` for mcp__plugin_g1t_g1t__issue or mcp__g1t__issue. */
255export function g1tTool(name) {
256 const match = /^mcp__(?:plugin_g1t_)?g1t__([a-z_]+)$/.exec(String(name ?? ""));
257 return match ? match[1] : null;
258}
259
260/**
261 * What a hook event asks for: the CLI calls to run in the background
262 * (`steps`), whether to start the session first, and for a subagent's
263 * events, which subagent (`agent`). Pure, so the tests can check it
264 * without a CLI.
265 */
266export function plan(event, env = process.env) {
267 const name = event?.hook_event_name;
268 const sid = event?.session_id;
269 if (!name || !sid || env.G1T_RECORD === "0") return null;
270 const cwd = event.cwd || process.cwd();
271 const job = { cwd, sid, steps: [] };
272 // Inside a subagent, what it does is its own session's.
273 if (event.agent_id) job.agent = { id: String(event.agent_id), type: event.agent_type || null };
274 switch (name) {
275 case "SessionStart": {
276 job.start = true;
277 const issue = Number(env.G1T_ISSUE);
278 if (issue > 0) job.issue = issue;
279 return job;
280 }
281 case "UserPromptSubmit": {
282 const prompt = typeof event.prompt === "string" ? event.prompt.replace(/^/, "") : "";
283 // A turn Claude Code starts itself for a background task carries that
284 // task's output, which is never sent: it is not a prompt of yours.
285 if (!prompt.trim() || /^\s*<task-notification>/.test(prompt)) return null;
286 if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(prompt, PROMPT_LIMIT)] });
287 // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent.
288 const skill = typedSkill(prompt);
289 if (skill) job.steps.push(skillStep(skill));
290 // What it is for titles the session, until it has a title (see hook and work).
291 const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(prompt);
292 if (intent) job.steps.push({ title: intent });
293 return job.steps.length ? job : null;
294 }
295 case "SubagentStart":
296 if (!job.agent) return null;
297 job.agent.start = true;
298 return job;
299 case "SubagentStop": {
300 if (!job.agent) return null;
301 const args = ["session", "end", "--reason", "done"];
302 const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : "";
303 // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value.
304 if (result) args.push(`--result=${clip(result, RESULT_LIMIT)}`);
305 job.steps.push({ args });
306 return job;
307 }
308 case "PostToolUse": {
309 if (!event.tool_name) return null;
310 const tool = event.tool_name;
311 const input = event.tool_input ?? {};
312 if (tool === "Skill") {
313 const skill = typeof input.skill === "string" ? input.skill.trim() : "";
314 if (!skill) return null;
315 // Its name, nothing more.
316 job.steps.push(skillStep(skill));
317 return job;
318 }
319 job.steps.push({ args: ["session", "log", "--kind", "tool_call", "--", summarizeTool(tool, input, cwd)] });
320 const touch = [];
321 if (WRITERS.has(tool)) {
322 const file = repoPath(input.file_path ?? input.notebook_path, cwd);
323 if (file) touch.push(`--file=${file}`);
324 }
325 if (AGENT_TOOLS.has(tool) && !job.agent) {
326 const response = event.tool_response ?? {};
327 const agentId = response.agentId ?? response.agent_id;
328 if (agentId) {
329 job.subagent = {
330 id: String(agentId),
331 type: input.subagent_type || null,
332 task: input.description ? clip(input.description, TASK_LIMIT) : null,
333 prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : clip(input.prompt, AGENT_PROMPT_LIMIT),
334 model: response.resolvedModel || null,
335 result: response.status === "completed" ? clip(agentResult(response), RESULT_LIMIT) || null : null,
336 };
337 }
338 }
339 if (SHELL_TOOLS.has(tool)) {
340 const issue = issueFromCommand(input.command);
341 if (issue) {
342 job.issue = issue;
343 touch.push("--issue", String(issue));
344 }
345 const branch = branchFromCommand(input.command);
346 if (branch) touch.push("--branch", branch);
347 if (isGitCommit(input.command)) job.steps.push({ touchHead: true });
348 if (isGitPush(input.command)) job.steps.push({ headCommit: true });
349 }
350 const claimed = g1tTool(tool) === "issue" && input.action === "claim" && Number(input.number) > 0 ? Number(input.number) : null;
351 if (claimed) {
352 job.issue = claimed;
353 touch.push("--issue", String(claimed));
354 }
355 if (touch.length) job.steps.push({ args: ["session", "touch", ...touch] });
356 return job;
357 }
358 case "Stop":
359 job.steps.push({ args: ["session", "heartbeat"] });
360 return job;
361 case "SessionEnd":
362 job.steps.push({ args: ["session", "end", "--reason", String(event.reason || "other")] });
363 job.end = true;
364 return job;
365 default:
366 return null;
367 }
368}
369
370// --- the machine -------------------------------------------------------
371
372function git(cwd, args) {
373 const out = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, timeout: 3000 });
374 return out.status === 0 ? out.stdout.trim() : null;
375}
376
377/** The g1t CLI on PATH, without running it. G1T_BIN names another. */
378export function findCli(env = process.env) {
379 if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null;
380 const names = process.platform === "win32" ? ["g1t.exe", "g1t"] : ["g1t"];
381 for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) {
382 if (!dir) continue;
383 for (const name of names) {
384 const candidate = join(dir.replace(/^"|"$/g, ""), name);
385 try {
386 if (statSync(candidate).isFile()) return candidate;
387 } catch {}
388 }
389 }
390 return null;
391}
392
393/** Runs the CLI. A .js or .mjs G1T_BIN (the tests' stand-in) runs under Node. */
394function cli(bin, args, options) {
395 const script = /\.m?js$/.test(bin);
396 return spawnSync(script ? process.execPath : bin, script ? [bin, ...args] : args, {
397 encoding: "utf8",
398 windowsHide: true,
399 ...options,
400 });
401}
402
403/** Where the plugin keeps its state: Claude Code's data folder for it. */
404export function dataDir(env = process.env) {
405 return env.CLAUDE_PLUGIN_DATA || join(homedir(), ".g1t", "claude-code");
406}
407
408function readJson(path, fallback) {
409 try {
410 return JSON.parse(readFileSync(path, "utf8"));
411 } catch {
412 return fallback;
413 }
414}
415
416function write(path, text) {
417 try {
418 mkdirSync(dirname(path), { recursive: true });
419 writeFileSync(path, text);
420 } catch {}
421}
422
423const readText = (path) => {
424 try {
425 return readFileSync(path, "utf8").trim() || null;
426 } catch {
427 return null;
428 }
429};
430
431const safeName = (text) => String(text).replace(/[^A-Za-z0-9_-]/g, "_");
432
433/**
434 * This session's files: its marker (it is in a g1t working copy), its g1t
435 * session id, its issue, the Agent calls waiting for their subagents, and
436 * each subagent's g1t session id.
437 */
438export function sessionFiles(sid, env = process.env) {
439 const dir = join(dataDir(env), "sessions");
440 const safe = safeName(sid);
441 return {
442 dir,
443 prefix: `${safe}.`,
444 marker: join(dir, `${safe}.json`),
445 id: join(dir, `${safe}.id`),
446 issue: join(dir, `${safe}.issue`),
447 turn: join(dir, `${safe}.turn`),
448 titled: join(dir, `${safe}.titled`),
449 pending: join(dir, `${safe}.pending.json`),
450 agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`),
451 };
452}
453
454/** Forgets sessions untouched for a week (ones that never saw SessionEnd). */
455function prune(dir) {
456 try {
457 const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
458 for (const name of readdirSync(dir)) {
459 const path = join(dir, name);
460 if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true });
461 }
462 } catch {}
463}
464
465// --- Agent calls and their subagents -----------------------------------
466
467/** Keeps an Agent call's task until its subagent starts (PreToolUse). */
468export function rememberTask(files, input) {
469 const pending = readJson(files.pending, []);
470 pending.push({
471 type: input?.subagent_type || null,
472 task: input?.description ? clip(input.description, TASK_LIMIT) : null,
473 prompt: input?.prompt ? clip(input.prompt, AGENT_PROMPT_LIMIT) : null,
474 at: Date.now(),
475 });
476 // An Agent call refused or never started is forgotten after an hour.
477 write(files.pending, JSON.stringify(pending.filter((p) => Date.now() - p.at < 3600 * 1000).slice(-50)));
478}
479
480/** The task of the oldest Agent call of `type` still waiting for its subagent, taken. */
481export function takeTask(files, type) {
482 const pending = readJson(files.pending, []);
483 const at = pending.findIndex((p) => !type || !p.type || p.type === type);
484 if (at < 0) return null;
485 const [task] = pending.splice(at, 1);
486 write(files.pending, JSON.stringify(pending));
487 return task;
488}
489
490// --- saying so once ----------------------------------------------------
491
492const NOT_RECORDED = "g1t: this session is not being recorded on g1t, because";
493
494const MESSAGES = {
495 missing: () => `${NOT_RECORDED} the g1t CLI is not installed. Install it (https://docs.g1t.sh/guides/cli/) and run \`g1t auth login\`. This is said once.`,
496 signed_out: () => `${NOT_RECORDED} the g1t CLI is not signed in. Run \`g1t auth login\`. This is said once.`,
497 outdated: (version) =>
498 `${NOT_RECORDED} the g1t CLI is ${version}, and the plugin needs ${MIN_CLI} or later. Update it by running the installer again: ${INSTALL}. This is said once.`,
499 refused: (command) =>
500 `${NOT_RECORDED} the g1t CLI refused \`g1t ${command}\`: it is older than the plugin, which needs ${MIN_CLI} or later. Update it by running the installer again: ${INSTALL}. This is said once.`,
501 old_node: (version) =>
502 `${NOT_RECORDED} its hooks need Node ${MIN_NODE} or later, and \`node\` is ${version}. Install a current Node (https://nodejs.org) and start Claude Code again. This is said once.`,
503};
504
505/** Remembers a problem (missing, signed_out, outdated, refused, old_node) and what it was about, or that there is none (null). */
506export function setProblem(env, problem, detail = null) {
507 const path = join(dataDir(env), "notice.json");
508 const notice = readJson(path, {});
509 if ((notice.problem ?? null) === problem && (notice.detail ?? null) === detail) return;
510 write(path, JSON.stringify({ problem, detail, shown: false }));
511}
512
513/** The message for a problem not shown yet, marking it shown. */
514export function takeNotice(env) {
515 const path = join(dataDir(env), "notice.json");
516 const notice = readJson(path, {});
517 if (!notice.problem || notice.shown || !MESSAGES[notice.problem]) return null;
518 write(path, JSON.stringify({ ...notice, shown: true }));
519 return MESSAGES[notice.problem](notice.detail);
520}
521
522/** Whether version a (1.2.3) is older than b. */
523export function older(a, b) {
524 const pa = String(a).split(".").map(Number);
525 const pb = String(b).split(".").map(Number);
526 for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
527 return false;
528}
529
530/**
531 * The CLI's version, from `g1t --version`, asked once for each build of
532 * it: kept with the file's size and time, so updating it asks again.
533 * Null when it cannot be told.
534 */
535export function cliVersion(bin, env = process.env) {
536 let stamp;
537 try {
538 const stat = statSync(bin);
539 stamp = `${bin}|${stat.size}|${stat.mtimeMs}`;
540 } catch {
541 return null;
542 }
543 const path = join(dataDir(env), "cli.json");
544 const kept = readJson(path, {});
545 if (kept.stamp === stamp) return kept.version ?? null;
546 const out = cli(bin, ["--version"], { timeout: 5000, env });
547 const version = out.status === 0 ? (/\b(\d+\.\d+\.\d+)\b/.exec(out.stdout ?? "")?.[1] ?? null) : null;
548 write(path, JSON.stringify({ stamp, version }));
549 return version;
550}
551
552/** Whether a working copy's remotes are on g1t (g1t.sh, or G1T_HOST's host). */
553export function isG1tRemote(urls, env = process.env) {
554 if (env.G1T_REPO) return true;
555 const hosts = ["g1t.sh", env.G1T_HOST].filter(Boolean).map((h) => h.toLowerCase());
556 return urls.some((url) => hosts.some((host) => url.toLowerCase().includes(host)));
557}
558
559// --- the background half -----------------------------------------------
560
561/** Hands a job to a detached copy of this script and returns at once. */
562function background(job, env = process.env) {
563 const payload = Buffer.from(JSON.stringify(job)).toString("base64url");
564 try {
565 const child = spawn(process.execPath, [SELF, "--worker", payload], {
566 cwd: job.cwd,
567 detached: true,
568 stdio: "ignore",
569 windowsHide: true,
570 env,
571 });
572 child.on("error", () => {});
573 child.unref();
574 } catch {}
575}
576
577/** Whether a session, as `--json` prints it, has a title; unreadable reads as having one, so nothing is replaced. */
578export function hasTitle(stdout) {
579 try {
580 const title = JSON.parse(stdout).title;
581 return typeof title === "string" && title.trim() !== "";
582 } catch {
583 return true;
584 }
585}
586
587/** The session id in a `g1t session start --json` answer. */
588function startedId(out) {
589 try {
590 return JSON.parse(out.stdout).id ?? null;
591 } catch {
592 return /\bses_[A-Za-z0-9]+/.exec(out.stdout ?? "")?.[0] ?? null;
593 }
594}
595
596/**
597 * Starts a subagent's session under `parentId` (again, when it was: the
598 * same session comes back, taking what is newly said), and keeps its id.
599 */
600function startSubagent(bin, job, files, agent, parentId, opts) {
601 const args = [
602 "session",
603 "start",
604 "--harness",
605 "claude_code",
606 "--harness-session-id",
607 subagentHarnessId(job.sid, agent.id),
608 "--parent",
609 parentId,
610 "--no-remember",
611 ];
612 if (agent.type) args.push(`--agent-type=${agent.type}`);
613 if (agent.task) args.push(`--task=${agent.task}`);
614 if (agent.prompt) args.push(`--prompt=${agent.prompt}`);
615 if (agent.model) args.push(`--model=${agent.model}`);
616 args.push("--json");
617 const out = cli(bin, args, { ...opts, env: { ...opts.env, G1T_SESSION: parentId } });
618 if (out.status === EXIT_AUTH) return { auth: true };
619 if (out.status === EXIT_USAGE) return { refused: "session start" };
620 const id = out.status === 0 ? startedId(out) : null;
621 if (id) write(files.agent(agent.id), id);
622 return { id };
623}
624
625/** Starts the session if it needs to, then runs the job's steps in order. */
626export function work(job, env = process.env) {
627 const bin = findCli(env);
628 if (!bin) return setProblem(env, "missing");
629 // An older CLI refuses some of what the hooks send: nothing is sent to it.
630 const version = cliVersion(bin, env);
631 if (version && older(version, MIN_CLI)) return setProblem(env, "outdated", version);
632 const files = sessionFiles(job.sid, env);
633 if (job.issue && !job.agent) write(files.issue, String(job.issue));
634 const opts = { cwd: job.cwd, timeout: 20000, env: { ...env } };
635 let id = readText(files.id);
636 if (job.start || !id) {
637 const issue = job.issue ?? readText(files.issue);
638 const args = ["session", "start", "--harness", "claude_code", "--harness-session-id", job.sid];
639 if (issue) args.push("--issue", String(issue));
640 args.push("--json");
641 const out = cli(bin, args, opts);
642 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
643 if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session start");
644 if (out.status !== 0) return;
645 setProblem(env, null);
646 id = startedId(out);
647 if (!id) return;
648 write(files.id, id);
649 if (job.envFile && /^[A-Za-z0-9_]+$/.test(id)) {
650 // Claude's own g1t commands act in this session, not another one in
651 // the same working copy. A plain assignment, which Claude Code gives
652 // PowerShell commands too; it reads the file at the session's first
653 // command, so a command before this (rare: it is the first second)
654 // uses the working copy's remembered session.
655 try {
656 writeFileSync(job.envFile, `export G1T_SESSION=${id}\n`, { flag: "a" });
657 } catch {}
658 }
659 }
660 // A subagent's events go to its own session, started first if need be.
661 let session = id;
662 if (job.agent) {
663 let child = readText(files.agent(job.agent.id));
664 if (job.agent.start || !child) {
665 const started = startSubagent(bin, job, files, job.agent, id, opts);
666 if (started.auth) return setProblem(env, "signed_out");
667 if (started.refused) return setProblem(env, "refused", started.refused);
668 child = started.id ?? child;
669 }
670 if (!child) return;
671 session = child;
672 }
673 opts.env.G1T_SESSION = session;
674 for (const step of job.steps) {
675 if (step.args) {
676 const args = step.key ? [...step.args.slice(0, -2), `--key=${step.key}`, ...step.args.slice(-2)] : step.args;
677 const out = cli(bin, args, opts);
678 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
679 if (out.status === EXIT_USAGE) return setProblem(env, "refused", step.args.slice(0, 2).join(" "));
680 } else if (step.headCommit) {
681 // Only once the push reached the remote: HEAD is on a remote branch.
682 const head = git(job.cwd, ["rev-parse", "HEAD"]);
683 const remote = git(job.cwd, ["branch", "-r", "--contains", "HEAD"]);
684 if (head && remote) {
685 cli(bin, ["api", "update_session", "-f", `id=${session}`, "-f", `head_commit=${head}`, "--silent"], opts);
686 const branch = git(job.cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
687 const touch = ["session", "touch", "--commit", head];
688 if (branch && branch !== "HEAD") touch.push("--branch", branch);
689 cli(bin, touch, opts);
690 }
691 } else if (step.title) {
692 // Only a session with no title: one set before (by the agent, a
693 // person, or this at an earlier prompt) is never replaced.
694 const seen = cli(bin, ["session", "heartbeat", "--json"], opts);
695 if (seen.status === EXIT_AUTH) return setProblem(env, "signed_out");
696 if (seen.status === EXIT_USAGE) return setProblem(env, "refused", "session heartbeat");
697 if (seen.status !== 0 || hasTitle(seen.stdout)) continue;
698 const out = cli(bin, ["session", "attach", `--title=${step.title}`], opts);
699 if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session attach");
700 } else if (step.touchHead) {
701 const head = git(job.cwd, ["rev-parse", "HEAD"]);
702 if (head) cli(bin, ["session", "touch", "--commit", head], opts);
703 }
704 }
705 // An Agent call answered: its subagent has its task, and once it
706 // finished, what it reported.
707 if (job.subagent) {
708 const started = startSubagent(bin, job, files, job.subagent, id, opts);
709 if (started.id && job.subagent.result) {
710 cli(bin, ["session", "end", "--reason", "done", `--result=${job.subagent.result}`], { ...opts, env: { ...opts.env, G1T_SESSION: started.id } });
711 }
712 }
713 if (job.end) {
714 try {
715 for (const name of readdirSync(files.dir)) if (name.startsWith(files.prefix)) rmSync(join(files.dir, name), { force: true });
716 } catch {}
717 }
718}
719
720// --- the hooks ---------------------------------------------------------
721
722/** What every session is told about trailers, so commits find their way back. */
723const TRAILER = "Add the trailer `G1t-Session: $G1T_SESSION` to the commits you make (git commit --trailer), so g1t ties them to this session.";
724
725/**
726 * SessionStart: marks the session as one in a g1t working copy, and
727 * returns what to print: context for Claude, and a notice for you, once.
728 * Null outside a g1t working copy, where nothing is recorded.
729 */
730export function sessionStart(event, env = process.env, bin = findCli(env)) {
731 const cwd = event.cwd || process.cwd();
732 const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
733 if (!gitDir) return null;
734 // The branch, from HEAD itself: also on a branch with no commits yet.
735 const branch = /^ref: refs\/heads\/(.+)$/m.exec(readText(join(gitDir, "HEAD")) ?? "")?.[1] ?? null;
736 const urls = (git(cwd, ["config", "--get-regexp", "^remote\\..*\\.url$"]) ?? "").split(/\r?\n/);
737 if (!isG1tRemote(urls, env)) return null;
738
739 const files = sessionFiles(event.session_id, env);
740 prune(files.dir);
741 write(files.marker, JSON.stringify({ cwd, git_dir: gitDir }));
742
743 const output = {};
744 if (!bin) setProblem(env, "missing");
745 const notice = takeNotice(env);
746 if (notice) output.systemMessage = notice;
747 if (!bin) return output;
748
749 const remembered = Number(readText(files.issue)) || null;
750 const handedOff = Number(env.G1T_ISSUE) || null;
751 const issue = remembered ?? handedOff ?? issueFromBranch(branch);
752 let context;
753 if (issue) {
754 const where = remembered ? "this session's claim" : handedOff ? "the work handed to it" : `the branch ${branch}`;
755 const out = cli(bin, ["work", "context", String(issue)], { cwd, timeout: CONTEXT_WAIT_MS, env });
756 if (out.status === 0 && out.stdout.trim()) {
757 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. ${TRAILER}`;
758 } else if (out.status === EXIT_AUTH) {
759 setProblem(env, "signed_out");
760 const now = takeNotice(env);
761 if (now) output.systemMessage = now;
762 } else {
763 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. ${TRAILER}`;
764 }
765 } else {
766 context = `g1t: this session is recorded on g1t and shows to the team, titled with what you are asked to do. Just work. If it turns into a change others should know about (a branch to push, a pull request), first look for its issue (\`g1t work list\`, \`g1t issue list\`) and claim it with \`g1t work claim N\`; if there is none, open and claim one with \`g1t work claim --title "…"\`. Never open issues for questions, exploration or trivial fixes. ${TRAILER}`;
767 }
768 if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context };
769 return output;
770}
771
772/** What a subagent is told as it starts: it is a session of its own, beside its siblings. */
773export function subagentContext(issue) {
774 const on = issue ? ` on issue #${issue}` : "";
775 const see = issue
776 ? `Before you change anything, run \`g1t work context ${issue}\` and \`g1t session list --issue ${issue} --subagents\`: other subagents of the same session may be on it, and you see what each touches.`
777 : "Before you change anything, run `g1t session list --subagents` to see the other subagents at work here.";
778 return `g1t: you are a subagent of a session recorded on g1t${on}, and g1t records you as a session of your own under it: the g1t commands and g1t tools you use act as that session. ${see} Claim only the issue you take on (\`g1t work claim N --note "what you are doing"\`), and if g1t reports an overlap with another session, settle it before going on. ${TRAILER}`;
779}
780
781/**
782 * PreToolUse: an Agent call's task is kept for its subagent; inside a
783 * subagent, its g1t commands and g1t claims act as its own session.
784 * Returns what to print, or null.
785 */
786export function preToolUse(event, env = process.env) {
787 const files = sessionFiles(event.session_id, env);
788 const tool = event.tool_name;
789 const input = event.tool_input ?? {};
790 if (AGENT_TOOLS.has(tool) && !event.agent_id) {
791 rememberTask(files, input);
792 return null;
793 }
794 const session = event.agent_id ? readText(files.agent(event.agent_id)) : readText(files.id);
795 if (!session) return null;
796 let updated = null;
797 const command = typeof input.command === "string" ? input.command : "";
798 const namesG1t = /\bg1t(?:\.exe)?\s/.test(command) || /G1T_SESSION\b/.test(command);
799 const setsIt = /G1T_SESSION\s*=/.test(command);
800 // A subagent's g1t commands act as its own session. Only these commands
801 // are changed. Claude Code checks its permission rules against the
802 // changed command; no permission decision is made here.
803 if (SHELL_TOOLS.has(tool) && event.agent_id && namesG1t && !setsIt && /^[A-Za-z0-9_]+$/.test(session)) {
804 updated = { ...input, command: `${sessionPrefix(tool, session)}${input.command}` };
805 }
806 const g1t = g1tTool(tool);
807 if (g1t === "issue" && ["claim", "release", "handoff"].includes(input.action) && !input.session_id) {
808 updated = { ...input, session_id: session };
809 }
810 return updated ? { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: updated } } : null;
811}
812
813/** Runs one hook: what it prints, and the job it hands to the background. */
814export function hook(event, env = process.env) {
815 if (env.G1T_RECORD === "0") return { output: null, job: null };
816 const name = event?.hook_event_name;
817 if (name === "PreToolUse") {
818 if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null };
819 return { output: preToolUse(event, env), job: null };
820 }
821 // Each prompt is a turn; a skill is recorded once in a turn, however it was run.
822 const files = event?.session_id ? sessionFiles(event.session_id, env) : null;
823 let turn = files ? Number(readText(files.turn)) || 0 : 0;
824 if (name === "UserPromptSubmit" && files && existsSync(files.marker)) write(files.turn, String(++turn));
825 const job = plan(event, env);
826 if (!job) return { output: null, job: null };
827 if (name === "SessionStart") {
828 const bin = findCli(env);
829 const output = sessionStart(event, env, bin);
830 if (!output) return { output: null, job: null };
831 if (env.CLAUDE_ENV_FILE) job.envFile = env.CLAUDE_ENV_FILE;
832 return { output: Object.keys(output).length ? output : null, job: bin ? job : null };
833 }
834 // Only sessions SessionStart found in a g1t working copy are recorded.
835 if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null };
836 for (const step of job.steps) if (step.skill) step.key = `skill:${safeName(step.skill)}:${turn}`;
837 // The first prompt with an intent titles the session; later ones do not try.
838 if (name === "UserPromptSubmit" && job.steps.some((step) => step.title)) {
839 if (existsSync(files.titled)) job.steps = job.steps.filter((step) => !step.title);
840 else write(files.titled, "1");
841 }
842 if (name === "SubagentStart") {
843 const task = takeTask(files, job.agent.type);
844 if (task) Object.assign(job.agent, { task: task.task, prompt: env.G1T_RECORD_PROMPTS === "0" ? null : task.prompt });
845 const issue = Number(readText(files.issue)) || null;
846 return { output: { hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: subagentContext(issue) } }, job };
847 }
848 const notice = name === "UserPromptSubmit" ? takeNotice(env) : null;
849 return { output: notice ? { systemMessage: notice } : null, job };
850}
851
852/**
853 * On a Node older than MIN_NODE, nothing is recorded: what to print (the
854 * notice, once, at a session's start or a prompt), or null. Undefined on a
855 * current Node.
856 */
857export function oldNode(event, env = process.env, version = process.versions.node) {
858 if (Number(String(version).split(".")[0]) >= MIN_NODE) return undefined;
859 if (env.G1T_RECORD === "0" || !["SessionStart", "UserPromptSubmit"].includes(event?.hook_event_name)) return null;
860 setProblem(env, "old_node", `v${version}`);
861 const notice = takeNotice(env);
862 return notice ? { systemMessage: notice } : null;
863}
864
865async function main() {
866 if (process.argv[2] === "--worker") {
867 work(JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8")));
868 return;
869 }
870 let raw = "";
871 for await (const chunk of process.stdin) raw += chunk;
872 const event = JSON.parse(raw || "{}");
873 const old = oldNode(event);
874 if (old !== undefined) {
875 if (old) process.stdout.write(JSON.stringify(old));
876 return;
877 }
878 const { output, job } = hook(event);
879 if (output) process.stdout.write(JSON.stringify(output));
880 if (job) background(job);
881}
882
883if (process.argv[1] && resolve(process.argv[1]) === SELF) {
884 main()
885 .catch(() => {})
886 .finally(() => process.exit(0));
887}