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
605 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-ad52ad80841a37960'1// Tests for the plugin's hooks: node --test plugins/claude-code/test/
2import assert from "node:assert/strict";
3import { spawnSync } from "node:child_process";
4import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
5import { tmpdir } from "node:os";
6import { join } from "node:path";
7import { afterEach, beforeEach, describe, test } from "node:test";
8import { fileURLToPath } from "node:url";
9
10import {
11 PROMPT_LIMIT,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.12 RESULT_LIMIT,
13 agentResult,
14 branchFromCommand,
15 g1tTool,
Merge branch 'worktree-agent-ad52ad80841a37960'16 hook,
Merge branch 'worktree-agent-ab3c375781462035f'17 MIN_CLI,
18 oldNode,
19 older,
20 typedSkill,
Merge branch 'worktree-agent-ad52ad80841a37960'21 isG1tRemote,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.22 isGitCommit,
Merge branch 'worktree-agent-ad52ad80841a37960'23 isGitPush,
24 issueFromBranch,
25 issueFromCommand,
26 plan,
27 redact,
28 sessionFiles,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.29 subagentHarnessId,
Merge branch 'worktree-agent-ad52ad80841a37960'30 summarizeTool,
31 work,
32} from "../hooks/g1t.mjs";
33
Merge branch 'worktree-agent-ab3c375781462035f'34import { CONTRACT, refusal } from "./fake-g1t.mjs";
35
Merge branch 'worktree-agent-ad52ad80841a37960'36const HERE = fileURLToPath(new URL(".", import.meta.url));
37const SCRIPT = join(HERE, "..", "hooks", "g1t.mjs");
38const FAKE = join(HERE, "fake-g1t.mjs");
39
40const base = { session_id: "abc-123", cwd: "/work/repo", transcript_path: "/t.jsonl", permission_mode: "default" };
41
42describe("plan", () => {
43 test("SessionStart starts the session", () => {
44 const job = plan({ ...base, hook_event_name: "SessionStart", source: "startup" }, {});
45 assert.equal(job.start, true);
46 assert.deepEqual(job.steps, []);
47 });
48
49 test("UserPromptSubmit logs the prompt, cut to the limit", () => {
50 const prompt = "x".repeat(PROMPT_LIMIT + 500);
51 const job = plan({ ...base, hook_event_name: "UserPromptSubmit", prompt }, {});
52 const args = job.steps[0].args;
53 assert.deepEqual(args.slice(0, 5), ["session", "log", "--kind", "prompt", "--"]);
54 assert.equal(args[5].length, PROMPT_LIMIT);
55 assert.ok(args[5].endsWith("…"));
56 });
57
Merge branch 'worktree-agent-ab3c375781462035f'58 test("a turn Claude Code starts itself for a background task is not a prompt", () => {
59 const notification = "<task-notification>\n<task-id>a1</task-id>\n<result>SECRET OUTPUT</result>\n</task-notification>";
60 assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: notification }, {}), null);
61 assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, {}).steps[0].args.at(-1), "hi");
62 });
63
Merge branch 'worktree-agent-ad52ad80841a37960'64 test("G1T_RECORD_PROMPTS=0 keeps prompts back, and G1T_RECORD=0 everything", () => {
65 assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, { G1T_RECORD_PROMPTS: "0" }), null);
66 assert.ok(plan({ ...base, hook_event_name: "Stop" }, { G1T_RECORD_PROMPTS: "0" }));
67 for (const hook_event_name of ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"]) {
68 assert.equal(plan({ ...base, hook_event_name, prompt: "hi", tool_name: "Bash" }, { G1T_RECORD: "0" }), null);
69 }
70 });
71
72 test("PostToolUse sends the tool and its input's summary, never its response", () => {
73 const event = {
74 ...base,
75 hook_event_name: "PostToolUse",
76 tool_name: "Bash",
77 tool_input: { command: "npm test", description: "Run the tests" },
78 tool_response: { stdout: "SECRET OUTPUT 1234", stderr: "", interrupted: false },
79 tool_use_id: "toolu_1",
80 };
81 const job = plan(event, {});
82 assert.deepEqual(job.steps, [{ args: ["session", "log", "--kind", "tool_call", "--", "Bash: npm test"] }]);
83 assert.ok(!JSON.stringify(job).includes("SECRET OUTPUT"));
84 });
85
86 test("PostToolUse after a git push updates the head commit", () => {
87 const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push -u origin 42-fix" } }, {});
88 assert.deepEqual(job.steps[1], { headCommit: true });
89 const other = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git status" } }, {});
90 assert.equal(other.steps.length, 1);
91 });
92
93 test("PostToolUse remembers the issue a command claims", () => {
94 const job = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "g1t work claim 42 --note x" } }, {});
95 assert.equal(job.issue, 42);
96 });
97
98 test("Stop sends a heartbeat and SessionEnd ends the session with its reason", () => {
99 assert.deepEqual(plan({ ...base, hook_event_name: "Stop", stop_hook_active: false }, {}).steps, [{ args: ["session", "heartbeat"] }]);
100 const end = plan({ ...base, hook_event_name: "SessionEnd", reason: "prompt_input_exit" }, {});
101 assert.deepEqual(end.steps, [{ args: ["session", "end", "--reason", "prompt_input_exit"] }]);
102 assert.equal(end.end, true);
103 });
104
105 test("events without a session, and other events, do nothing", () => {
106 assert.equal(plan({ hook_event_name: "Stop" }, {}), null);
107 assert.equal(plan({ ...base, hook_event_name: "PreToolUse", tool_name: "Bash" }, {}), null);
108 });
109});
110
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.111// Payloads as Claude Code sends them (code.claude.com/docs/en/hooks).
112const fixtures = {
113 subagentStart: {
114 session_id: "abc-123",
115 transcript_path: "/t.jsonl",
116 cwd: "/work/repo",
117 hook_event_name: "SubagentStart",
118 agent_id: "agent-def456",
119 agent_type: "Explore",
120 },
121 subagentStop: {
122 session_id: "abc-123",
123 transcript_path: "/t.jsonl",
124 cwd: "/work/repo",
125 permission_mode: "default",
126 hook_event_name: "SubagentStop",
127 stop_hook_active: false,
128 agent_id: "def456",
129 agent_type: "Explore",
130 agent_transcript_path: "/t/abc-123/subagents/agent-def456.jsonl",
131 last_assistant_message: "Analysis complete. Found 3 potential issues...",
132 background_tasks: [],
133 session_crons: [],
134 },
135 agentCompleted: {
136 ...base,
137 hook_event_name: "PostToolUse",
138 tool_name: "Agent",
139 tool_input: { description: "Find API endpoints", prompt: "Find all API endpoints", subagent_type: "Explore" },
140 tool_response: {
141 status: "completed",
142 agentId: "a4d2c8f1e0b3a297",
143 content: [{ type: "text", text: "Found 12 endpoints in src/api." }],
144 resolvedModel: "claude-sonnet-4-5",
145 totalTokens: 12450,
146 totalDurationMs: 48211,
147 totalToolUseCount: 7,
148 usage: { input_tokens: 8320, output_tokens: 410 },
149 },
150 tool_use_id: "toolu_9",
151 },
152 agentLaunched: {
153 ...base,
154 hook_event_name: "PostToolUse",
155 tool_name: "Agent",
156 tool_input: { description: "Review the diff", prompt: "Review it", subagent_type: "code-reviewer", run_in_background: true },
157 tool_response: { status: "async_launched", agentId: "b5", description: "Review the diff", prompt: "Review it", outputFile: "/tmp/b5.out", resolvedModel: "claude-sonnet-4-5" },
158 },
159 inSubagent: {
160 ...base,
161 hook_event_name: "PostToolUse",
162 agent_id: "agent-def456",
163 agent_type: "Explore",
164 tool_name: "Edit",
165 tool_input: { file_path: "/work/repo/src/api/routes.ts", old_string: "a", new_string: "b" },
166 tool_response: { filePath: "/work/repo/src/api/routes.ts", structuredPatch: "SECRET PATCH" },
167 },
168 skill: { ...base, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "work-on-issue", args: "42" }, tool_response: { success: true, commandName: "work-on-issue" } },
169};
170
171describe("subagents", () => {
172 test("SubagentStart starts a session of its own for the subagent", () => {
173 const job = plan(fixtures.subagentStart, {});
174 assert.deepEqual(job.agent, { id: "agent-def456", type: "Explore", start: true });
175 assert.deepEqual(job.steps, []);
176 assert.equal(subagentHarnessId("abc-123", "agent-def456"), "abc-123/agent/def456");
177 assert.equal(subagentHarnessId("abc-123", "def456"), "abc-123/agent/def456", "the Agent tool's id names the same subagent");
178 });
179
180 test("SubagentStop ends it with its final message, cut to the limit", () => {
181 const job = plan(fixtures.subagentStop, {});
182 assert.deepEqual(job.agent, { id: "def456", type: "Explore" });
Merge branch 'worktree-agent-ab3c375781462035f'183 assert.deepEqual(job.steps, [{ args: ["session", "end", "--reason", "done", "--result=Analysis complete. Found 3 potential issues..."] }]);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.184 const long = plan({ ...fixtures.subagentStop, last_assistant_message: "z".repeat(RESULT_LIMIT * 2) }, {});
Merge branch 'worktree-agent-ab3c375781462035f'185 assert.equal(long.steps[0].args.at(-1).length, "--result=".length + RESULT_LIMIT);
186 const dash = plan({ ...fixtures.subagentStop, last_assistant_message: "- Found 3 issues" }, {});
187 assert.equal(dash.steps[0].args.at(-1), "--result=- Found 3 issues", "a message that starts with a dash is still a value");
188 assert.equal(refusal(dash.steps[0].args), null);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.189 });
190
191 test("an Agent call gives its subagent the task, and once finished its final message, and nothing else of its answer", () => {
192 const job = plan(fixtures.agentCompleted, {});
193 assert.deepEqual(job.subagent, {
194 id: "a4d2c8f1e0b3a297",
195 type: "Explore",
196 task: "Find API endpoints",
197 prompt: "Find all API endpoints",
198 model: "claude-sonnet-4-5",
199 result: "Found 12 endpoints in src/api.",
200 });
201 assert.deepEqual(job.steps, [{ args: ["session", "log", "--kind", "tool_call", "--", "Agent: Find API endpoints"] }]);
202 assert.ok(!JSON.stringify(job).includes("12450") && !JSON.stringify(job).includes("8320"));
203 const launched = plan(fixtures.agentLaunched, {});
204 assert.equal(launched.subagent.id, "b5");
205 assert.equal(launched.subagent.result, null, "a background subagent reports at SubagentStop");
206 assert.equal(plan(fixtures.agentCompleted, { G1T_RECORD_PROMPTS: "0" }).subagent.prompt, null);
207 assert.equal(agentResult({ content: "plain" }), "plain");
208 });
209
210 test("what a subagent does is its own session's, and the files it writes are touched", () => {
211 const job = plan(fixtures.inSubagent, {});
212 assert.deepEqual(job.agent, { id: "agent-def456", type: "Explore" });
213 assert.deepEqual(job.steps, [
214 { args: ["session", "log", "--kind", "tool_call", "--", "Edit: src/api/routes.ts"] },
Merge branch 'worktree-agent-ab3c375781462035f'215 { args: ["session", "touch", "--file=src/api/routes.ts"] },
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.216 ]);
217 assert.ok(!JSON.stringify(job).includes("SECRET PATCH"));
218 });
219
220 test("a skill is its name only", () => {
Merge branch 'worktree-agent-ab3c375781462035f'221 assert.deepEqual(plan(fixtures.skill, {}).steps, [{ args: ["session", "log", "--kind", "skill", "--", "work-on-issue"], skill: "work-on-issue" }]);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.222 });
223
224 test("branches made, commits, claims and g1t's MCP tools are touched", () => {
225 const bash = (command) => plan({ ...base, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command } }, {});
226 assert.deepEqual(bash("git checkout -b 42-fix").steps.at(-1), { args: ["session", "touch", "--branch", "42-fix"] });
227 assert.deepEqual(bash('git commit -m "x" --trailer "G1t-Session: $G1T_SESSION"').steps.at(-1), { touchHead: true });
228 assert.deepEqual(bash("g1t work claim 42").steps.at(-1), { args: ["session", "touch", "--issue", "42"] });
229 const mcp = plan({ ...base, hook_event_name: "PostToolUse", tool_name: "mcp__plugin_g1t_g1t__issue", tool_input: { action: "claim", repo: "a/b", number: 7 } }, {});
230 assert.equal(mcp.issue, 7);
231 assert.equal(branchFromCommand("git switch -c feat/x && echo"), "feat/x");
232 assert.equal(branchFromCommand("git branch -d old"), null);
233 assert.equal(isGitCommit("git -C repo commit -am x"), true);
234 assert.equal(g1tTool("mcp__g1t__session"), "session");
235 assert.equal(g1tTool("mcp__github__issue"), null);
236 });
237
238 test("a hand-off's issue comes with the session", () => {
239 assert.equal(plan({ ...base, hook_event_name: "SessionStart" }, { G1T_ISSUE: "12" }).issue, 12);
240 });
241});
242
Merge branch 'worktree-agent-ad52ad80841a37960'243describe("summaries", () => {
244 test("say what a tool was called with, in one short line", () => {
245 assert.equal(summarizeTool("Edit", { file_path: "/work/repo/src/a.ts", old_string: "x", new_string: "y" }, "/work/repo"), "Edit: src/a.ts");
246 assert.equal(summarizeTool("Grep", { pattern: "TODO", path: "src" }, "/w"), "Grep: TODO in src");
247 assert.equal(summarizeTool("WebFetch", { url: "https://example.com", prompt: "p" }), "WebFetch: https://example.com");
248 assert.equal(summarizeTool("Bash", { command: "echo one\necho two" }), "Bash: echo one …");
249 assert.equal(summarizeTool("mcp__g1t__issue", { action: "get", number: 4 }), "mcp__g1t__issue: get");
250 assert.ok(summarizeTool("Bash", { command: "y".repeat(1000) }).length <= 200);
251 });
252
253 test("hide what looks like a secret", () => {
254 const line = redact('curl -H "Authorization: Bearer g1t_abcdefghijklmnop" https://u:pw@x.com GITHUB_TOKEN=ghp_123456789012 --token abc');
255 assert.ok(!line.includes("abcdefghijklmnop"));
256 assert.ok(!line.includes("ghp_123456789012"));
257 assert.ok(!line.includes("u:pw@"));
258 assert.ok(!line.includes("--token abc"));
259 });
260
261 test("find the issue in a branch name", () => {
262 assert.equal(issueFromBranch("42-fix-login"), 42);
263 assert.equal(issueFromBranch("issue-7"), 7);
264 assert.equal(issueFromBranch("fix/123-thing"), 123);
265 assert.equal(issueFromBranch("main"), null);
266 assert.equal(issueFromBranch("release/1.2"), null);
267 assert.equal(issueFromBranch("HEAD"), null);
268 assert.equal(issueFromCommand("g1t session attach --issue 9"), 9);
269 assert.equal(issueFromCommand("g1t pr create --issue=12 --draft"), 12);
270 assert.equal(isGitPush("cd x && git -C repo push"), true);
271 assert.equal(isGitPush("git pushd"), false);
272 assert.equal(isG1tRemote(["remote.origin.url https://g1t.sh/a/b.git"], {}), true);
273 assert.equal(isG1tRemote(["remote.origin.url https://example.com/a/b.git"], {}), false);
274 assert.equal(isG1tRemote(["remote.origin.url https://git.acme.dev/a/b.git"], { G1T_HOST: "git.acme.dev" }), true);
275 });
276});
277
278describe("the plugin's files", () => {
279 const root = join(HERE, "..", "..", "..");
280 test("parse as JSON", () => {
281 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"]) {
282 assert.doesNotThrow(() => JSON.parse(readFileSync(join(root, file), "utf8")), file);
283 }
284 });
285
286 test("the AGENTS.md snippet is the one in the docs", () => {
287 const snippet = readFileSync(join(root, "plugins/other-harnesses/AGENTS.snippet.md"), "utf8").replaceAll("\r\n", "\n");
288 const guide = readFileSync(join(root, "apps/docs/src/content/docs/guides/claude-code-plugin.md"), "utf8").replaceAll("\r\n", "\n");
289 const block = /```md title="AGENTS.md"\n([\s\S]*?)```/.exec(guide)?.[1];
290 assert.equal(block, snippet);
291 });
292});
293
294describe("with a working copy", () => {
295 let dir, env, log;
296
297 const git = (...args) => spawnSync("git", args, { cwd: dir, encoding: "utf8" });
Merge branch 'worktree-agent-ab3c375781462035f'298 const allCalls = () =>
Merge branch 'worktree-agent-ad52ad80841a37960'299 existsSync(log)
300 ? readFileSync(log, "utf8")
301 .trim()
302 .split("\n")
303 .filter(Boolean)
304 .map((l) => JSON.parse(l))
305 : [];
Merge branch 'worktree-agent-ab3c375781462035f'306 // The calls that do something: not the version check before them.
307 const calls = () => allCalls().filter((c) => c.args[0] !== "--version");
Merge branch 'worktree-agent-ad52ad80841a37960'308
309 beforeEach(() => {
310 dir = mkdtempSync(join(tmpdir(), "g1t-hook-"));
311 log = join(dir, "calls.jsonl");
312 git("init", "-q", "-b", "42-fix-login");
313 git("remote", "add", "origin", "https://g1t.sh/acme/app.git");
314 env = { PATH: process.env.PATH, G1T_BIN: FAKE, FAKE_G1T_LOG: log, CLAUDE_PLUGIN_DATA: join(dir, ".data") };
315 });
Merge branch 'worktree-agent-ab3c375781462035f'316 afterEach(() => {
317 // Every call the hooks made is one the real CLI takes (the stand-in refuses the rest).
318 const refused = allCalls().filter((c) => refusal(c.args));
319 rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
320 assert.deepEqual(refused, [], "the CLI refuses these calls");
321 });
Merge branch 'worktree-agent-ad52ad80841a37960'322
323 test("SessionStart gives Claude the branch's issue's work context", () => {
324 const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env);
325 const context = output.hookSpecificOutput.additionalContext;
326 assert.equal(output.hookSpecificOutput.hookEventName, "SessionStart");
327 assert.match(context, /issue #42/);
328 assert.match(context, /Definition of done: it works/);
329 assert.deepEqual(calls()[0].args, ["work", "context", "42"]);
330 assert.equal(job.start, true);
331 });
332
333 test("the background half starts the session once, then logs into it", () => {
334 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
335 const { job } = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env);
336 // No SessionStart marked this session as one in a g1t working copy.
337 assert.equal(job, null);
338 work(plan({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "fix the login" }, env), env);
339 const [start, logged] = calls();
340 assert.deepEqual(start.args, ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123", "--json"]);
341 assert.deepEqual(logged, { args: ["session", "log", "--kind", "prompt", "--", "fix the login"], session: "ses_fake01" });
342 assert.equal(readFileSync(sessionFiles(base.session_id, env).id, "utf8"), "ses_fake01");
343 });
344
345 test("hooks after SessionStart record; SessionEnd forgets the session", () => {
346 hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env);
347 const files = sessionFiles(base.session_id, env);
348 assert.ok(existsSync(files.marker));
349 const { job } = hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env);
350 work(job, env);
351 work(plan({ ...base, cwd: dir, hook_event_name: "SessionEnd", reason: "clear" }, env), env);
352 const args = calls().map((c) => c.args.join(" "));
353 assert.ok(args.includes("session heartbeat"));
354 assert.ok(args.includes("session end --reason clear"));
355 assert.ok(!existsSync(files.marker) && !existsSync(files.id));
356 });
357
358 test("a push that reached the remote updates the head commit", () => {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.359 git("-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "one");
Merge branch 'worktree-agent-ad52ad80841a37960'360 git("update-ref", "refs/remotes/origin/42-fix-login", "HEAD");
361 const head = git("rev-parse", "HEAD").stdout.trim();
362 work(plan({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: { command: "git push" } }, env), env);
363 const update = calls().find((c) => c.args[0] === "api");
364 assert.deepEqual(update.args, ["api", "update_session", "-f", "id=ses_fake01", "-f", `head_commit=${head}`, "--silent"]);
365 });
366
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.367 test("each subagent is a session of its own under this one, whatever order its hooks arrive in", () => {
368 const at = (event) => ({ ...event, cwd: dir });
369 hook(at({ ...base, hook_event_name: "SessionStart", source: "startup" }), env);
370 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
371 // Claude calls Agent; its task waits for the subagent.
372 const pre = hook(at({ ...base, hook_event_name: "PreToolUse", tool_name: "Agent", tool_input: fixtures.agentCompleted.tool_input }), env);
373 assert.equal(pre.output, null);
374 const started = hook(at(fixtures.subagentStart), env);
375 assert.match(started.output.hookSpecificOutput.additionalContext, /session of your own/);
376 assert.match(started.output.hookSpecificOutput.additionalContext, /g1t session list --issue 42 --subagents|g1t session list --subagents/);
377 assert.equal(started.job.agent.task, "Find API endpoints");
378 work(started.job, env);
379 // What it does goes to its own session.
380 const edited = { ...fixtures.inSubagent.tool_input, file_path: join(dir, "src", "api", "routes.ts") };
381 work(hook(at({ ...fixtures.inSubagent, tool_input: edited }), env).job, env);
382 // Its g1t commands act as it.
383 const bash = hook(at({ ...base, hook_event_name: "PreToolUse", agent_id: "agent-def456", agent_type: "Explore", tool_name: "Bash", tool_input: { command: "g1t work claim 42" } }), env);
384 assert.equal(bash.output.hookSpecificOutput.updatedInput.command, "export G1T_SESSION=ses_sub_def456; g1t work claim 42");
385 const mcp = hook(at({ ...base, hook_event_name: "PreToolUse", agent_id: "agent-def456", tool_name: "mcp__plugin_g1t_g1t__issue", tool_input: { action: "claim", repo: "acme/app", number: 42 } }), env);
386 assert.equal(mcp.output.hookSpecificOutput.updatedInput.session_id, "ses_sub_def456");
387 // Outside a subagent, a command is left alone.
388 assert.equal(hook(at({ ...base, hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "g1t work claim 42" } }), env).output, null);
389 work(hook(at(fixtures.subagentStop), env).job, env);
390 // A late SubagentStart starts it again, which g1t answers with the same, ended, session.
391 work(hook(at(fixtures.subagentStart), env).job, env);
392 const log = calls();
393 const childStarts = log.filter((c) => c.args[1] === "start" && c.args.includes("--parent"));
394 assert.ok(childStarts.length >= 2);
395 const first = childStarts[0].args;
396 assert.deepEqual(first.slice(0, 8), ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123/agent/def456", "--parent", "ses_fake01"]);
397 assert.ok(first.includes("--no-remember"));
Merge branch 'worktree-agent-ab3c375781462035f'398 assert.ok(first.includes("--agent-type=Explore"));
399 assert.ok(first.includes("--task=Find API endpoints"));
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.400 const edit = log.find((c) => c.args[2] === "--kind" && c.args.at(-1) === "Edit: src/api/routes.ts");
401 assert.equal(edit.session, "ses_sub_def456");
402 const touched = log.find((c) => c.args[1] === "touch");
Merge branch 'worktree-agent-ab3c375781462035f'403 assert.deepEqual(touched, { args: ["session", "touch", "--file=src/api/routes.ts"], session: "ses_sub_def456" });
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.404 const ended = log.find((c) => c.args[1] === "end");
Merge branch 'worktree-agent-ab3c375781462035f'405 assert.deepEqual(ended, { args: ["session", "end", "--reason", "done", "--result=Analysis complete. Found 3 potential issues..."], session: "ses_sub_def456" });
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.406 });
407
408 test("a subagent whose stop arrives first is started, then ended", () => {
409 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
410 work(hook({ ...fixtures.subagentStop, cwd: dir }, env).job, env);
411 const [, start, end] = calls().filter((c) => c.args[0] === "session");
412 assert.ok(start.args.includes("--parent"));
413 assert.equal(end.args[1], "end");
414 assert.equal(end.session, "ses_sub_def456");
415 });
416
417 test("an Agent call that finished ends its subagent with what it reported", () => {
418 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
419 work(hook({ ...fixtures.agentCompleted, cwd: dir }, env).job, env);
420 const log = calls().filter((c) => c.args[0] === "session");
421 assert.equal(log.find((c) => c.args[1] === "log").session, "ses_fake01", "the Agent call is the parent's");
422 const end = log.find((c) => c.args[1] === "end");
Merge branch 'worktree-agent-ab3c375781462035f'423 assert.deepEqual(end, { args: ["session", "end", "--reason", "done", "--result=Found 12 endpoints in src/api."], session: "ses_sub_a4d2c8f1e0b3a297" });
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.424 });
425
426 test("a hand-off names its issue at the start", () => {
427 git("checkout", "-q", "-b", "main");
428 const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, G1T_ISSUE: "12" });
429 assert.match(output.hookSpecificOutput.additionalContext, /issue #12 \(from the work handed to it\)/);
430 assert.match(output.hookSpecificOutput.additionalContext, /G1t-Session/);
431 work(job, env);
432 const start = calls().find((c) => c.args[1] === "start");
433 assert.ok(start.args.includes("--issue") && start.args.includes("12"));
434 });
435
Merge branch 'worktree-agent-ad52ad80841a37960'436 test("a missing CLI records nothing and says so once", () => {
437 const missing = { ...env, G1T_BIN: join(dir, "no-such-g1t") };
438 const first = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing);
439 assert.match(first.output.systemMessage, /not installed/);
440 assert.equal(first.job, null);
441 const second = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, missing);
442 assert.equal(second.output, null);
443 assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, missing).job, null);
444 assert.deepEqual(calls(), []);
445 });
446
447 test("a signed-out CLI is said once, at the next prompt", () => {
448 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, CLAUDE_PLUGIN_DATA: env.CLAUDE_PLUGIN_DATA });
449 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, { ...env, FAKE_G1T_EXIT: "4" });
450 const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env);
451 assert.match(first.output.systemMessage, /not signed in/);
452 const second = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env);
453 assert.equal(second.output, null);
454 });
455
456 test("outside a g1t repository nothing happens", () => {
457 git("remote", "set-url", "origin", "https://example.com/acme/app.git");
458 const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
459 assert.equal(output, null);
460 assert.equal(job, null);
461 assert.equal(hook({ ...base, cwd: dir, hook_event_name: "Stop" }, env).job, null);
462 assert.deepEqual(calls(), []);
463 });
464
465 test("the script answers SessionStart on stdin quickly, as JSON", async () => {
466 const started = Date.now();
467 const out = spawnSync(process.execPath, [SCRIPT], {
468 input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }),
469 encoding: "utf8",
470 env: { ...process.env, ...env },
471 });
472 const took = Date.now() - started;
473 assert.equal(out.status, 0);
474 assert.match(JSON.parse(out.stdout).hookSpecificOutput.additionalContext, /issue #42/);
475 assert.ok(took < 3000, `took ${took} ms`);
476 // The detached half starts the session after the hook has returned.
477 const id = sessionFiles(base.session_id, env).id;
478 for (let i = 0; i < 100 && !existsSync(id); i++) await new Promise((done) => setTimeout(done, 50));
479 assert.equal(readFileSync(id, "utf8"), "ses_fake01");
480 await new Promise((done) => setTimeout(done, 200));
481 });
482
483 test("the script prints nothing for a prompt, and never fails on bad input", () => {
484 const prompt = spawnSync(process.execPath, [SCRIPT], {
485 input: JSON.stringify({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hello" }),
486 encoding: "utf8",
487 env: { ...process.env, ...env },
488 });
489 assert.equal(prompt.status, 0);
490 assert.equal(prompt.stdout, "");
491 const bad = spawnSync(process.execPath, [SCRIPT], { input: "not json", encoding: "utf8", env: { ...process.env, ...env } });
492 assert.equal(bad.status, 0);
493 assert.equal(bad.stdout, "");
494 });
Merge branch 'worktree-agent-ab3c375781462035f'495
496 test("a CLI older than the plugin needs records nothing and says so once, with how to update it", () => {
497 const old = { ...env, FAKE_G1T_VERSION: "0.3.1" };
498 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, old);
499 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, old);
500 work(plan({ ...base, cwd: dir, hook_event_name: "Stop" }, old), old);
501 // SessionStart still asks it for the issue's context, which every CLI gives.
502 assert.deepEqual(calls().map((c) => c.args.join(" ")), ["work context 42"], "nothing is recorded with it");
503 assert.equal(allCalls().filter((c) => c.args[0] === "--version").length, 1, "the version is asked once for each build of the CLI");
504 const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, old);
505 assert.match(first.output.systemMessage, /g1t CLI is 0\.3\.1, and the plugin needs 0\.3\.2 or later/);
506 assert.match(first.output.systemMessage, /curl -fsSL https:\/\/g1t\.sh\/install \| sh/);
507 assert.match(first.output.systemMessage, /irm https:\/\/g1t\.sh\/install\.ps1 \| iex/);
508 assert.equal(hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, old).output, null);
509 assert.equal(older("0.3.1", MIN_CLI), true);
510 assert.equal(older("0.3.2", MIN_CLI), false);
511 assert.equal(older("0.10.0", MIN_CLI), false);
512 assert.equal(older("1.0.0", MIN_CLI), false);
513 });
514
515 test("a CLI that refuses a command (exit 2) is said once, as one to update", () => {
516 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
517 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, { ...env, FAKE_G1T_EXIT: "2" });
518 const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env);
519 assert.match(first.output.systemMessage, /refused `g1t session start`/);
520 assert.match(first.output.systemMessage, /installer again/);
521 assert.equal(hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env).output, null);
522 });
523
524 test("the stand-in CLI refuses what the real one refuses", () => {
525 assert.equal(CONTRACT.minimum_version, MIN_CLI, "the hooks ask for the CLI the contract needs");
526 assert.match(refusal(["session", "log", "--kind", "tool-calls", "--", "x"]), /takes prompt, tool_call, skill/);
527 assert.match(refusal(["session", "log", "--kind", "note", "--", "x"]), /not note/, "the hooks never send a note");
528 assert.match(refusal(["session", "end", "--result", "- a list"]), /unexpected argument/);
529 assert.equal(refusal(["session", "end", "--result=- a list"]), null);
530 assert.match(refusal(["session", "touch"]), /needs one of/);
531 assert.match(refusal(["session", "heartbeat", "--now"]), /takes no --now/);
532 assert.match(refusal(["session", "start", "--harness", "claude-managed-agents", "--harness-session-id", "x"]), /takes claude_code/);
533 assert.match(refusal(["work", "context", "abc"]), /not abc/);
534 assert.match(refusal(["session", "pause"]), /takes 0 argument/);
535 assert.equal(refusal(["api", "update_session", "-f", "id=ses_1", "-f", "head_commit=abc", "--silent"]), null);
536 });
537
538 test("PowerShell commands are read as Bash ones are, and a subagent's act as its session", () => {
539 const ps = (command, extra = {}) => ({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "PowerShell", tool_input: { command }, ...extra });
540 assert.equal(plan(ps("g1t work claim 42"), env).issue, 42);
541 assert.deepEqual(plan(ps("git checkout -b 42-fix; git status"), env).steps.at(-1), { args: ["session", "touch", "--branch", "42-fix"] });
542 assert.deepEqual(plan(ps("git commit -m x"), env).steps.at(-1), { touchHead: true });
543 assert.deepEqual(plan(ps("git push origin 42-fix"), env).steps.at(-1), { headCommit: true });
544 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
545 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
546 work(hook({ ...fixtures.subagentStart, cwd: dir }, env).job, env);
547 const pre = (tool_name, command) =>
548 hook({ ...base, cwd: dir, hook_event_name: "PreToolUse", agent_id: "agent-def456", agent_type: "Explore", tool_name, tool_input: { command } }, env).output;
549 assert.equal(pre("PowerShell", "g1t work claim 42").hookSpecificOutput.updatedInput.command, "$env:G1T_SESSION='ses_sub_def456'; g1t work claim 42");
550 assert.equal(pre("PowerShell", "g1t.exe session view").hookSpecificOutput.updatedInput.command, "$env:G1T_SESSION='ses_sub_def456'; g1t.exe session view");
551 assert.equal(pre("Bash", "echo $G1T_SESSION").hookSpecificOutput.updatedInput.command, "export G1T_SESSION=ses_sub_def456; echo $G1T_SESSION");
552 assert.equal(pre("PowerShell", "$env:G1T_SESSION='ses_other'; g1t work claim 42"), null, "a session the command sets itself is kept");
553 assert.equal(pre("PowerShell", "Get-ChildItem"), null, "other commands are left alone");
554 // The tool call is recorded as Claude wrote it.
555 const after = plan(ps("$env:G1T_SESSION='ses_sub_def456'; g1t work claim 42", { agent_id: "agent-def456" }), env);
556 assert.deepEqual(after.steps[0].args.at(-1), "PowerShell: g1t work claim 42");
557 assert.equal(summarizeTool("Bash", { command: "export G1T_SESSION=ses_1; g1t pr view" }), "Bash: g1t pr view");
558 });
559
560 test("a skill typed as /plugin:name is recorded once in its turn, however Claude Code then runs it", () => {
561 assert.equal(typedSkill("/g1t:work-on-issue 42"), "g1t:work-on-issue");
562 assert.equal(typedSkill("/g1t:status"), "g1t:status");
563 assert.equal(typedSkill("/clear"), null, "Claude Code's own commands are not skills");
564 assert.equal(typedSkill("look at /g1t:status"), null);
565 hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env);
566 work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env);
567 const typed = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "/g1t:work-on-issue 42" }, env).job;
568 assert.deepEqual(typed.steps.map((s) => s.args.slice(0, 4)), [["session", "log", "--kind", "prompt"], ["session", "log", "--kind", "skill"]]);
569 work(typed, env);
570 const tool = hook({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "g1t:work-on-issue", args: "42" } }, env).job;
571 work(tool, env);
572 const skills = calls().filter((c) => c.args.includes("skill"));
573 assert.equal(skills.length, 2);
574 assert.deepEqual(skills[0].args, ["session", "log", "--kind", "skill", "--key=skill:work-on-issue:1", "--", "g1t:work-on-issue"]);
575 assert.equal(skills[1].args[4], "--key=skill:work-on-issue:1", "the same key: g1t keeps the first");
576 // Another skill in the turn has a key of its own; the next prompt is another turn.
577 const next = hook({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "g1t:coordinate" } }, env).job;
578 assert.equal(next.steps[0].key, "skill:coordinate:1");
579 hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "go on" }, { ...env, G1T_RECORD_PROMPTS: "0" });
580 const later = hook({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "g1t:work-on-issue" } }, env).job;
581 assert.equal(later.steps[0].key, "skill:work-on-issue:2", "a turn whose prompt is not sent is still a turn");
582 // Prompts kept back: the typed skill's name is still recorded, the prompt is not.
583 const quiet = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "/g1t:status" }, { ...env, G1T_RECORD_PROMPTS: "0" }).job;
584 assert.deepEqual(quiet.steps.map((s) => s.args.at(-1)), ["g1t:status"]);
585 });
586
587 test("Claude's own commands get the session's id once it has started", () => {
588 const envFile = join(dir, "env.sh");
589 const { job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, CLAUDE_ENV_FILE: envFile });
590 assert.ok(!existsSync(envFile), "nothing is written before the session's id is known");
591 work(job, env);
592 // A plain assignment: Claude Code gives it to PowerShell commands too.
593 assert.equal(readFileSync(envFile, "utf8"), "export G1T_SESSION=ses_fake01\n");
594 });
595
596 test("on a Node older than 18 nothing is recorded, and that is said once", () => {
597 const at = (hook_event_name) => ({ ...base, cwd: dir, hook_event_name });
598 assert.equal(oldNode(at("SessionStart"), env, "18.0.0"), undefined);
599 const first = oldNode(at("SessionStart"), env, "16.20.2");
600 assert.match(first.systemMessage, /need Node 18 or later, and `node` is v16\.20\.2/);
601 assert.equal(oldNode(at("UserPromptSubmit"), env, "16.20.2"), null);
602 assert.equal(oldNode(at("PostToolUse"), env, "16.20.2"), null);
603 assert.deepEqual(calls(), []);
604 });
Merge branch 'worktree-agent-ad52ad80841a37960'605});

This file's history is long; its oldest lines are credited to the oldest commit read.