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

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