The g1t plugin is 0.2.8: sessions in a shared working copy no longer claim each other's files, a session started from VS Code is titled with what was typed, and files are named from the repository's root. Overlaps: every heartbeat (each turn's Stop), updates and acknowledge let the CLI report every uncommitted file in the working copy as the session's own, so several Claude Code sessions in one checkout each claimed all of them and were told they overlapped (three did on flagon-io/g1t today, editing unrelated files). The plugin now runs the CLI with G1T_TOUCH=0, and gives Claude's own shell the same through Claude Code's env file; it says what the session wrote itself: what Claude edits or writes, as before, and what a shell command changes while it runs (sed, a script, a formatter), from the uncommitted files before it (PreToolUse) and after it (PostToolUse) (dirtyFiles, shellChanges), never what was uncommitted already. Titles: VS Code puts <ide_opened_file>…</ide_opened_file> ahead of the prompt, which read as a turn Claude Code starts itself, so the session took its next prompt as its title; editor context blocks (<ide_*>, <system-reminder>) are skipped first. Paths: a file was named from Claude Code's current directory, so after cd apps/web, apps/web/app/x.ts was recorded as app/x.ts; it is named from the working copy's top (git rev-parse --show-toplevel, asked once per directory). Checked: node --test plugins/claude-code/test/hooks.test.mjs (58; new: a shell command's writes and not what was uncommitted before, no CLI call left to guess (checked after every working-copy test), IDE-prefixed titles, paths from the root); scripts/plugins-release.test.mjs (8).
- G1t-Session:
- ses_01m4zzpc96ect8d41mffgr1a45
6 files+137−100/6 viewed
| 7 | 7 | "name": "g1t", | |
| 8 | 8 | "source": "./plugins/claude-code", | |
| 9 | 9 | "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 10 | − | "version": "0.2.7", | |
| 10 | + | "version": "0.2.8", | |
| 11 | 11 | "category": "development" | |
| 12 | 12 | } | |
| 13 | 13 | ] |
| 96 | 96 | | You send a prompt that says what it is for, while the session has no title | `g1t session heartbeat --json`, then, if the session has no title, `g1t session attach --title INTENT` | Its title: what the prompt is for, as its first line or sentence without pasted text, image placeholders (`[Image #1]`), code blocks or markdown, up to 80 characters, cut on a word with `…`. A slash command, or a prompt that is only pasted text or images, gives no title, and the next prompt titles the session. A title the session already has (one Claude, you or an earlier prompt set) is never replaced. Not sent with prompts kept back. | | |
| 97 | 97 | | Claude uses a tool | `g1t session log --kind tool_call TEXT` | The tool's name and one line on what it was called with: the command, file, pattern or address, up to 200 characters, with anything that looks like a token or password replaced by `…`. | | |
| 98 | 98 | | Claude uses a skill, or you run one (`/g1t:work-on-issue 42`) | `g1t session log --kind skill NAME` | The skill's name, nothing more, once in a turn. A plugin's skill or command you type as `/plugin:name` is recorded even with prompts kept back. | | |
| 99 | − | | Claude writes a file, makes a branch or a commit, or claims an issue | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--issue`) | What it touched: the path from the repository's root, the branch, the commit, the issue. g1t ties the session to the pull requests and issues these lead to, and tells two sessions that touch the same file. | | |
| 99 | + | | Claude writes a file, makes a branch or a commit, or claims an issue | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--issue`) | What it touched: the path from the repository's root, the branch, the commit, the issue. A file is one Claude edited or wrote, or one a shell command changed while it ran (the uncommitted files before and after it), never what was uncommitted already, so sessions sharing a working copy do not claim each other's work. g1t ties the session to the pull requests and issues these lead to, and tells two sessions that touch the same file. | | |
| 100 | 100 | | A `git push` reaches the remote | `g1t api update_session -f id=ID -f head_commit=SHA` and `g1t session touch --commit SHA --branch BRANCH` | The commit you pushed and its branch, so the session's entries line up with the code and it is tied to the branch's pull request. | | |
| 101 | 101 | | Claude starts a subagent | `g1t session start --parent SESSION --agent-type TYPE --task TASK --prompt PROMPT` | A session of its own for the subagent, under this one, with its kind and the task it was given (its prompt's first 4,000 characters). See [Subagents](#subagents). | | |
| 102 | 102 | | A subagent finishes | `g1t session end --result MESSAGE` | Its final message, up to 8,000 characters: what it reported to Claude. | |
| 1 | 1 | { | |
| 2 | 2 | "name": "g1t", | |
| 3 | − | "version": "0.2.7", | |
| 3 | + | "version": "0.2.8", | |
| 4 | 4 | "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 5 | 5 | "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" }, | |
| 6 | 6 | "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/", |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | /** A path as the repository names it: relative to its root, with forward slashes; null outside it. */ | |
| 122 | − | export function repoPath(path, cwd) { | |
| 122 | + | export function repoPath(path, cwd, root = repoRoot(cwd)) { | |
| 123 | 123 | if (typeof path !== "string" || !path) return null; | |
| 124 | − | if (!isAbsolute(path)) return path.replaceAll("\\", "/").replace(/^\.\//, ""); | |
| 125 | − | if (!cwd) return null; | |
| 126 | − | const rel = relative(cwd, path); | |
| 124 | + | // From the working copy's top, wherever the shell has moved to since | |
| 125 | + | // (`cd apps/web` leaves Claude Code's cwd there); from cwd outside one. | |
| 126 | + | const base = root || cwd; | |
| 127 | + | if (!isAbsolute(path)) { | |
| 128 | + | if (!cwd || !root) return path.replaceAll("\\", "/").replace(/^\.\//, ""); | |
| 129 | + | path = resolve(cwd, path); | |
| 130 | + | } | |
| 131 | + | if (!base) return null; | |
| 132 | + | const rel = relative(base, path); | |
| 127 | 133 | if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null; | |
| 128 | 134 | return rel.replaceAll("\\", "/"); | |
| 129 | 135 | } | |
| ⋯ | |||
| 218 | 224 | /** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */ | |
| 219 | 225 | const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i; | |
| 220 | 226 | ||
| 227 | + | /** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */ | |
| 228 | + | const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i; | |
| 229 | + | ||
| 221 | 230 | /** | |
| 222 | 231 | * What a prompt is for, as a session's title: its first line or sentence, | |
| 223 | 232 | * without code blocks or markdown, whitespace collapsed, clipped at | |
| ⋯ | |||
| 234 | 243 | text = text | |
| 235 | 244 | .replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n") | |
| 236 | 245 | .replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " "); | |
| 246 | + | // Context an editor puts before what was typed (VS Code's | |
| 247 | + | // <ide_opened_file>, <ide_selection>) is not a turn the harness started. | |
| 248 | + | while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, ""); | |
| 237 | 249 | if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null; | |
| 238 | 250 | text = text | |
| 239 | 251 | .replace(/```[\s\S]*?(```|$)/g, "\n") | |
| ⋯ | |||
| 363 | 375 | } | |
| 364 | 376 | } | |
| 365 | 377 | if (SHELL_TOOLS.has(tool)) { | |
| 378 | + | // What the command wrote (sed, a script, a formatter): the files it | |
| 379 | + | // changed, against what PreToolUse saw before it ran. | |
| 380 | + | if (event.session_id && event.tool_use_id) { | |
| 381 | + | const kept = sessionFiles(event.session_id, env).shell(event.tool_use_id); | |
| 382 | + | const before = readJson(kept, null); | |
| 383 | + | rmSync(kept, { force: true }); | |
| 384 | + | for (const file of shellChanges(before, before ? dirtyFiles(cwd) : null).slice(0, 100)) touch.push(`--file=${file}`); | |
| 385 | + | } | |
| 366 | 386 | const issue = issueFromCommand(input.command); | |
| 367 | 387 | if (issue) { | |
| 368 | 388 | job.issue = issue; | |
| ⋯ | |||
| 406 | 426 | return out.status === 0 ? out.stdout.trim() : null; | |
| 407 | 427 | } | |
| 408 | 428 | ||
| 429 | + | /** The most uncommitted files compared around one shell command. */ | |
| 430 | + | const MAX_DIRTY = 2000; | |
| 431 | + | ||
| 432 | + | /** | |
| 433 | + | * The working copy's uncommitted files (modified, added, deleted, renamed | |
| 434 | + | * by their new name, untracked; never ignored), each with what it is now: | |
| 435 | + | * its modified time and size, or "gone". Paths from the top of the working | |
| 436 | + | * copy. Null outside one. | |
| 437 | + | */ | |
| 438 | + | export function dirtyFiles(cwd) { | |
| 439 | + | const root = repoRoot(cwd); | |
| 440 | + | if (!root) return null; | |
| 441 | + | const out = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", windowsHide: true, timeout: 3000 }); | |
| 442 | + | if (out.status !== 0) return null; | |
| 443 | + | const files = {}; | |
| 444 | + | const records = out.stdout.split("\0"); | |
| 445 | + | let count = 0; | |
| 446 | + | for (let i = 0; i < records.length && count < MAX_DIRTY; i++) { | |
| 447 | + | const record = records[i]; | |
| 448 | + | if (record.length < 4) continue; | |
| 449 | + | // A rename or copy names its new path, then its old one. | |
| 450 | + | if (record[0] === "R" || record[0] === "C") i++; | |
| 451 | + | const path = record.slice(3); | |
| 452 | + | try { | |
| 453 | + | const stat = statSync(join(root, path)); | |
| 454 | + | files[path] = `${stat.mtimeMs}:${stat.size}`; | |
| 455 | + | } catch { | |
| 456 | + | files[path] = "gone"; | |
| 457 | + | } | |
| 458 | + | count++; | |
| 459 | + | } | |
| 460 | + | return files; | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | /** | |
| 464 | + | * What a shell command wrote, from the uncommitted files before it and | |
| 465 | + | * after: each one new since, or changed since. One it committed or put | |
| 466 | + | * back is not a write. Others writing in the same working copy during the | |
| 467 | + | * command are counted too, but only then, never what they wrote before. | |
| 468 | + | */ | |
| 469 | + | export function shellChanges(before, after) { | |
| 470 | + | if (!before || !after) return []; | |
| 471 | + | return Object.keys(after).filter((path) => before[path] !== after[path]); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | const roots = new Map(); | |
| 475 | + | /** The top of the working copy `cwd` is in, asked once per directory; null outside one. */ | |
| 476 | + | function repoRoot(cwd) { | |
| 477 | + | if (!cwd) return null; | |
| 478 | + | if (!roots.has(cwd)) roots.set(cwd, git(cwd, ["rev-parse", "--show-toplevel"]) || null); | |
| 479 | + | return roots.get(cwd); | |
| 480 | + | } | |
| 481 | + | ||
| 409 | 482 | /** The g1t CLI on PATH, without running it. G1T_BIN names another. */ | |
| 410 | 483 | export function findCli(env = process.env) { | |
| 411 | 484 | if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null; | |
| ⋯ | |||
| 429 | 502 | encoding: "utf8", | |
| 430 | 503 | windowsHide: true, | |
| 431 | 504 | ...options, | |
| 505 | + | // The hooks say what this session wrote (see shellChanges); the CLI's | |
| 506 | + | // own guess, every uncommitted file in the working copy, would claim | |
| 507 | + | // what other sessions sharing it wrote too. | |
| 508 | + | env: { ...(options?.env ?? process.env), G1T_TOUCH: "0" }, | |
| 432 | 509 | }); | |
| 433 | 510 | } | |
| 434 | 511 | ||
| ⋯ | |||
| 482 | 559 | turn: join(dir, `${safe}.turn`), | |
| 483 | 560 | titled: join(dir, `${safe}.titled`), | |
| 484 | 561 | task: (key) => join(dir, `${safe}.task.${safeName(key)}.json`), | |
| 562 | + | shell: (toolUseId) => join(dir, `${safe}.shell.${safeName(toolUseId)}.json`), | |
| 485 | 563 | unrecorded: join(dir, `${safe}.unrecorded.json`), | |
| 486 | 564 | agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`), | |
| 487 | 565 | }; | |
| ⋯ | |||
| 810 | 888 | // command, so a command before this (rare: it is the first second) | |
| 811 | 889 | // uses the working copy's remembered session. | |
| 812 | 890 | try { | |
| 813 | − | writeFileSync(job.envFile, `export G1T_SESSION=${id}\n`, { flag: "a" }); | |
| 891 | + | // G1T_TOUCH=0: they say what they wrote through the hooks, as cli() does. | |
| 892 | + | writeFileSync(job.envFile, `export G1T_SESSION=${id}\nexport G1T_TOUCH=0\n`, { flag: "a" }); | |
| 814 | 893 | } catch {} | |
| 815 | 894 | } | |
| 816 | 895 | } | |
| ⋯ | |||
| 1056 | 1135 | const name = event?.hook_event_name; | |
| 1057 | 1136 | if (name === "PreToolUse") { | |
| 1058 | 1137 | if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null }; | |
| 1138 | + | // The uncommitted files as a shell command finds them, so PostToolUse can say which it wrote. | |
| 1139 | + | if (SHELL_TOOLS.has(event.tool_name) && event.tool_use_id) { | |
| 1140 | + | const before = dirtyFiles(event.cwd || process.cwd()); | |
| 1141 | + | if (before) write(sessionFiles(event.session_id, env).shell(event.tool_use_id), JSON.stringify(before)); | |
| 1142 | + | } | |
| 1059 | 1143 | const output = preToolUse(event, env); | |
| 1060 | 1144 | const context = updateContext(event, env, { throttle: true }); | |
| 1061 | 1145 | if (!context) return { output, job: null }; | |
| 75 | 75 | ||
| 76 | 76 | function main() { | |
| 77 | 77 | const args = process.argv.slice(2); | |
| 78 | − | appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null })}\n`); | |
| 78 | + | // `touch` only when the caller left the CLI to guess touched files from the whole working copy. | |
| 79 | + | const guesses = process.env.G1T_TOUCH === "0" ? {} : { touch: process.env.G1T_TOUCH ?? "unset" }; | |
| 80 | + | appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null, ...guesses })}\n`); | |
| 79 | 81 | const refused = refusal(args); | |
| 80 | 82 | if (refused) { | |
| 81 | 83 | process.stderr.write(`g1t: ${refused} See --help.\n`); |
| 19 | 19 | hasTitle, | |
| 20 | 20 | hook, | |
| 21 | 21 | intentTitle, | |
| 22 | + | repoPath, | |
| 22 | 23 | MIN_CLI, | |
| 23 | 24 | TITLE_LIMIT, | |
| 24 | 25 | oldNode, | |
| ⋯ | |||
| 88 | 89 | assert.equal(intentTitle("[Image #1] the PR link is missing on Home"), "The PR link is missing on Home"); | |
| 89 | 90 | assert.equal(intentTitle("[Image: source: C:\\tmp\\1.png]\nmake it render"), "Make it render"); | |
| 90 | 91 | assert.equal(intentTitle('<pasted_content id="1">only a paste'), null); | |
| 92 | + | // What VS Code puts ahead of what was typed is not a turn Claude Code started. | |
| 93 | + | assert.equal( | |
| 94 | + | intentTitle("<ide_opened_file>The user opened the file c:\\a\\Dockerfile in the IDE.</ide_opened_file>\nWhether it's the desktop app or the page, it crashes"), | |
| 95 | + | "Whether it's the desktop app or the page, it crashes", | |
| 96 | + | ); | |
| 97 | + | assert.equal(intentTitle("<ide_selection>const a = 1;</ide_selection>\n<ide_opened_file>a.ts</ide_opened_file>\nrename this"), "Rename this"); | |
| 98 | + | assert.equal(intentTitle("<ide_opened_file>a.ts</ide_opened_file>\n/clear"), null); | |
| 91 | 99 | // Not intents: slash commands, turns Claude Code starts, nothing to say. | |
| 92 | 100 | for (const prompt of ["/clear", "/compact keep the plan", "/g1t:status 4", "<command-name>/clear</command-name>", '<local-command-stdout lang="en">ok</local-command-stdout>', "```\ncode only\n```", "?", "", null]) { | |
| 93 | 101 | assert.equal(intentTitle(prompt), null, String(prompt)); | |
| ⋯ | |||
| 288 | 296 | test("say what a tool was called with, in one short line", () => { | |
| 289 | 297 | assert.equal(summarizeTool("Edit", { file_path: "/work/repo/src/a.ts", old_string: "x", new_string: "y" }, "/work/repo"), "Edit: src/a.ts"); | |
| 290 | 298 | assert.equal(summarizeTool("Grep", { pattern: "TODO", path: "src" }, "/w"), "Grep: TODO in src"); | |
| 299 | + | // From the top of the working copy, wherever the shell has moved to. | |
| 300 | + | assert.equal(repoPath("/work/repo/apps/web/app/a.ts", "/work/repo/apps/web", "/work/repo"), "apps/web/app/a.ts"); | |
| 301 | + | assert.equal(repoPath("app/a.ts", "/work/repo/apps/web", "/work/repo"), "apps/web/app/a.ts"); | |
| 302 | + | assert.equal(repoPath("/elsewhere/a.ts", "/work/repo/apps/web", "/work/repo"), null); | |
| 303 | + | assert.equal(repoPath("/work/repo/src/a.ts", "/work/repo", null), "src/a.ts"); | |
| 291 | 304 | assert.equal(summarizeTool("WebFetch", { url: "https://example.com", prompt: "p" }), "WebFetch: https://example.com"); | |
| 292 | 305 | assert.equal(summarizeTool("Bash", { command: "echo one\necho two" }), "Bash: echo one …"); | |
| 293 | 306 | assert.equal(summarizeTool("mcp__g1t__issue", { action: "get", number: 4 }), "mcp__g1t__issue: get"); | |
| ⋯ | |||
| 360 | 373 | afterEach(() => { | |
| 361 | 374 | // Every call the hooks made is one the real CLI takes (the stand-in refuses the rest). | |
| 362 | 375 | const refused = allCalls().filter((c) => refusal(c.args)); | |
| 376 | + | // And none let the CLI claim every uncommitted file as this session's (the stand-in logs `touch` then). | |
| 377 | + | const guessing = allCalls().filter((c) => c.touch !== undefined); | |
| 363 | 378 | rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); | |
| 364 | 379 | assert.deepEqual(refused, [], "the CLI refuses these calls"); | |
| 380 | + | assert.deepEqual(guessing, [], "these calls let the CLI guess what was touched"); | |
| 365 | 381 | }); | |
| 366 | 382 | ||
| 367 | 383 | test("SessionStart gives Claude the branch's issue's work context", () => { | |
| ⋯ | |||
| 386 | 402 | assert.equal(readFileSync(sessionFiles(base.session_id, env).id, "utf8"), "ses_fake01"); | |
| 387 | 403 | }); | |
| 388 | 404 | ||
| 405 | + | test("a shell command's writes are this session's; what was uncommitted before it is not", () => { | |
| 406 | + | const { job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 407 | + | work(job, env); | |
| 408 | + | // Another session's edit, uncommitted in the same working copy. | |
| 409 | + | writeFileSync(join(dir, "theirs.md"), "theirs"); | |
| 410 | + | const command = { ...base, cwd: dir, tool_name: "Bash", tool_use_id: "tu_1", tool_input: { command: "sed -i s/a/b/ mine.md" } }; | |
| 411 | + | hook({ ...command, hook_event_name: "PreToolUse" }, env); | |
| 412 | + | writeFileSync(join(dir, "mine.md"), "mine"); | |
| 413 | + | const after = hook({ ...command, hook_event_name: "PostToolUse" }, env).job; | |
| 414 | + | const touched = after.steps.filter((step) => step.args?.[1] === "touch").flatMap((step) => step.args.filter((arg) => arg.startsWith("--file="))); | |
| 415 | + | assert.ok(touched.includes("--file=mine.md"), String(touched)); | |
| 416 | + | assert.ok(!touched.includes("--file=theirs.md"), String(touched)); | |
| 417 | + | // Read once: a command run again under the same id finds nothing kept. | |
| 418 | + | assert.equal(existsSync(sessionFiles(base.session_id, env).shell("tu_1")), false); | |
| 419 | + | }); | |
| 420 | + | ||
| 421 | + | test("the CLI never guesses touched files from the whole working copy", () => { | |
| 422 | + | const { job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 423 | + | work(job, env); | |
| 424 | + | work(plan({ ...base, cwd: dir, hook_event_name: "Stop" }, env), env); | |
| 425 | + | const heartbeat = calls().find((call) => call.args[0] === "session" && call.args[1] === "heartbeat"); | |
| 426 | + | assert.ok(heartbeat, "a heartbeat was sent"); | |
| 427 | + | assert.equal(heartbeat.touch, undefined); | |
| 428 | + | }); | |
| 429 | + | ||
| 389 | 430 | test("background retrieval reaches the next tool without auto-acknowledging", () => { | |
| 390 | 431 | // A CLI that acknowledges: an older one is told to update instead (below). | |
| 391 | 432 | env.FAKE_G1T_VERSION = "0.3.3"; | |
| ⋯ | |||
| 808 | 849 | assert.ok(!existsSync(envFile), "nothing is written before the session's id is known"); | |
| 809 | 850 | work(job, env); | |
| 810 | 851 | // A plain assignment: Claude Code gives it to PowerShell commands too. | |
| 811 | − | assert.equal(readFileSync(envFile, "utf8"), "export G1T_SESSION=ses_fake01\n"); | |
| 852 | + | assert.equal(readFileSync(envFile, "utf8"), "export G1T_SESSION=ses_fake01\nexport G1T_TOUCH=0\n"); | |
| 812 | 853 | }); | |
| 813 | 854 | ||
| 814 | 855 | test("on a Node older than 18 nothing is recorded, and that is said once", () => { | |
Comments
Sign in to comment.