Skip to content

g1t's plugin marketplace for Claude Code: the g1t plugin, which records sessions, claims and hands off issues, and adds g1t's MCP server. Published from flagon-io/g1t, where changes and issues go.

Commit

g1t 0.2.9, from flagon-io/g1t@0058af3d0a7d

Published by scripts/plugins-release.mjs from https://g1t.sh/flagon-io/g1t/commit/0058af3. Change the plugin there; this repository is overwritten.

g1tbotcommitted Parentbfdb6deBrowse files
4 files+149−250/4 viewed
+1−1
1010 "name": "g1t",
1111 "source": "./plugins/g1t",
1212 "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.",
13− "version": "0.2.7",
13+ "version": "0.2.9",
1414 "category": "development"
1515 }
1616 ]
+1−1
11 # g1t plugins
22
3−g1t's plugin marketplace for Claude Code. Its one plugin, `g1t` (0.2.7),
3+g1t's plugin marketplace for Claude Code. Its one plugin, `g1t` (0.2.9),
44 records your Claude Code sessions on g1t, gives Claude the issue it is
55 working on, claims and hands off work, and adds g1t's MCP server, skills
66 and commands.
+1−1
11 {
22 "name": "g1t",
3− "version": "0.2.7",
3+ "version": "0.2.9",
44 "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.",
55 "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" },
66 "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/",
+146−22
3636 // that is not a tool's output: an Agent call's answer, which is its
3737 // subagent's final message (as SubagentStop gives it) and its id. Prompts
3838 // are not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0.
39+// Whatever is sent has what looks like a credential taken out first
40+// (redact): command lines, prompts, titles, subagents' prompts and results.
41+// On g1t a prompt shows only to whoever typed it.
3942 //
4043 // A session that starts where there is no g1t remote is recorded from the
4144 // prompt, or the git or g1t command, after which there is one (lateStart).
108111 return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
109112 }
110113
111−/** Hides what looks like a secret in a command line before it is sent. */
114+/** What a credential is replaced with, as g1t itself does (crates/scan/src/redact.rs). */
115+export const REDACTED = "[redacted]";
116+
117+/** A value that names a secret rather than holding one: `$TOKEN`, `${{ secrets.X }}`, `process.env.X`. */
118+function reference(value) {
119+ const v = String(value).replace(/^["'`]|["'`]$/g, "");
120+ return !v || /^[$%<]/.test(v) || v.includes("secrets.") || v.includes("env.") || v.includes("[redacted") || v === "…";
121+}
122+
123+/** `token: string` in code is a type, not a token. */
124+const TYPES = new Set(["string", "String", "str", "&str", "number", "bool", "boolean", "null", "None", "undefined", "true", "false"]);
125+
126+const PREFIXED =
127+ /\b(g1t|ghp|gho|ghu|ghs|ghr|github_pat|glpat|gloas|glrt|glptt|gldt|xox[abeprs]|sk-ant|sk-proj|sk-svcacct|sk-admin|sk|rk|whsec|npm|hf|pypi|dop_v1|shpat|shpss|sntrys|lin_api|figd)([-_])([A-Za-z0-9_-]{12,})/g;
128+const ASSIGNED =
129+ /([A-Za-z0-9_.-]*(?:token|secret|passw(?:or)?d|pwd|api[_-]?key|apikey|private[_-]?key|access[_-]?key|auth[_-]?key|signing[_-]?key|session[_-]?key|credentials?|webhook[_-]?url|dsn|connection[_-]?string)["']?\s*(?:=|:)\s*)("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|[^\s,;&)}\]]+)/gi;
130+const QUERY =
131+ /([?&](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|client_secret|sig|signature|password|code|x-amz-signature|x-amz-credential|x-amz-security-token)=)[^&\s#"'`]+/gi;
132+const FLAG = /(--(?:token|password|passwd|secret|api-key|apikey|access-token|auth-token|client-secret|private-key)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
133+
134+/**
135+ * Hides what looks like a credential before it is sent: in a command
136+ * line, a prompt, a title, or what a subagent was asked and reported.
137+ * g1t hides them again as it records and shows them; this keeps them from
138+ * leaving the machine at all.
139+ */
112140 export function redact(text) {
141+ const keep = (whole, head, value) => (reference(value) ? whole : `${head}${REDACTED}`);
113142 return String(text)
114− .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…")
115− .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…")
116− .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…")
117− .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…")
118− .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@");
143+ .replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g, REDACTED)
144+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, REDACTED)
145+ .replace(PREFIXED, (whole, prefix, sep, body) => (/[A-Za-z]/.test(body) && /\d/.test(body) ? `${prefix}${sep}${REDACTED}` : whole))
146+ .replace(/\b(?:(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})\b/g, REDACTED)
147+ .replace(/\b((?:proxy-)?authorization\s*[:=]\s*(?:(?:bearer|basic|token|bot|digest)\s+)?)([^\s"'`,;]+)/gi, keep)
148+ .replace(/\b(bearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi, keep)
149+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, `$1${REDACTED}@`)
150+ .replace(QUERY, `$1${REDACTED}`)
151+ .replace(FLAG, keep)
152+ .replace(/((?:^|\s)(?:-u|--user)\s+[^\s:]+:)\S+/g, `$1${REDACTED}`)
153+ .replace(ASSIGNED, (whole, head, value) => (TYPES.has(value) ? whole : keep(whole, head, value)));
119154 }
120155
156+/** Text a person or an agent wrote, as it is sent: credentials out, cut to `limit`. */
157+export function scrub(text, limit) {
158+ return clip(redact(text), limit);
159+}
160+
121161 /** A path as the repository names it: relative to its root, with forward slashes; null outside it. */
122−export function repoPath(path, cwd) {
162+export function repoPath(path, cwd, root = repoRoot(cwd)) {
123163 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);
164+ // From the working copy's top, wherever the shell has moved to since
165+ // (`cd apps/web` leaves Claude Code's cwd there); from cwd outside one.
166+ const base = root || cwd;
167+ if (!isAbsolute(path)) {
168+ if (!cwd || !root) return path.replaceAll("\\", "/").replace(/^\.\//, "");
169+ path = resolve(cwd, path);
170+ }
171+ if (!base) return null;
172+ const rel = relative(base, path);
127173 if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
128174 return rel.replaceAll("\\", "/");
129175 }
218264 /** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */
219265 const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i;
220266
267+/** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */
268+const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i;
269+
221270 /**
222271 * What a prompt is for, as a session's title: its first line or sentence,
223272 * without code blocks or markdown, whitespace collapsed, clipped at
234283 text = text
235284 .replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n")
236285 .replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " ");
286+ // Context an editor puts before what was typed (VS Code's
287+ // <ide_opened_file>, <ide_selection>) is not a turn the harness started.
288+ while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, "");
237289 if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null;
238290 text = text
239291 .replace(/```[\s\S]*?(```|$)/g, "\n")
307359 // A turn Claude Code starts itself for a background task carries that
308360 // task's output, which is never sent: it is not a prompt of yours.
309361 if (!prompt.trim() || /^\s*<task-notification>/.test(prompt)) return null;
310− if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(prompt, PROMPT_LIMIT)] });
362+ if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", scrub(prompt, PROMPT_LIMIT)] });
311363 // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent.
312364 const skill = typedSkill(prompt);
313365 if (skill) job.steps.push(skillStep(skill));
314366 // What it is for titles the session, until it has a title (see hook and work).
315− const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(prompt);
367+ const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(redact(prompt));
316368 if (intent) job.steps.push({ title: intent });
317369 return job.steps.length ? job : null;
318370 }
325377 const args = ["session", "end", "--reason", "done"];
326378 const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : "";
327379 // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value.
328− if (result) args.push(`--result=${clip(result, RESULT_LIMIT)}`);
380+ if (result) args.push(`--result=${scrub(result, RESULT_LIMIT)}`);
329381 job.steps.push({ args });
330382 return job;
331383 }
353405 job.subagent = {
354406 id: String(agentId),
355407 type: input.subagent_type || null,
356− task: input.description ? clip(input.description, TASK_LIMIT) : null,
357− prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : clip(input.prompt, AGENT_PROMPT_LIMIT),
408+ task: input.description ? scrub(input.description, TASK_LIMIT) : null,
409+ prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : scrub(input.prompt, AGENT_PROMPT_LIMIT),
358410 model: response.resolvedModel || null,
359− result: response.status === "completed" ? clip(agentResult(response), RESULT_LIMIT) || null : null,
411+ result: response.status === "completed" ? scrub(agentResult(response), RESULT_LIMIT) || null : null,
360412 };
361413 // Its task, kept for its subagent, is no other subagent's (see work).
362414 if (event.tool_use_id) job.toolUseId = String(event.tool_use_id);
363415 }
364416 }
365417 if (SHELL_TOOLS.has(tool)) {
418+ // What the command wrote (sed, a script, a formatter): the files it
419+ // changed, against what PreToolUse saw before it ran.
420+ if (event.session_id && event.tool_use_id) {
421+ const kept = sessionFiles(event.session_id, env).shell(event.tool_use_id);
422+ const before = readJson(kept, null);
423+ rmSync(kept, { force: true });
424+ for (const file of shellChanges(before, before ? dirtyFiles(cwd) : null).slice(0, 100)) touch.push(`--file=${file}`);
425+ }
366426 const issue = issueFromCommand(input.command);
367427 if (issue) {
368428 job.issue = issue;
406466 return out.status === 0 ? out.stdout.trim() : null;
407467 }
408468
469+/** The most uncommitted files compared around one shell command. */
470+const MAX_DIRTY = 2000;
471+
472+/**
473+ * The working copy's uncommitted files (modified, added, deleted, renamed
474+ * by their new name, untracked; never ignored), each with what it is now:
475+ * its modified time and size, or "gone". Paths from the top of the working
476+ * copy. Null outside one.
477+ */
478+export function dirtyFiles(cwd) {
479+ const root = repoRoot(cwd);
480+ if (!root) return null;
481+ const out = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", windowsHide: true, timeout: 3000 });
482+ if (out.status !== 0) return null;
483+ const files = {};
484+ const records = out.stdout.split("\0");
485+ let count = 0;
486+ for (let i = 0; i < records.length && count < MAX_DIRTY; i++) {
487+ const record = records[i];
488+ if (record.length < 4) continue;
489+ // A rename or copy names its new path, then its old one.
490+ if (record[0] === "R" || record[0] === "C") i++;
491+ const path = record.slice(3);
492+ try {
493+ const stat = statSync(join(root, path));
494+ files[path] = `${stat.mtimeMs}:${stat.size}`;
495+ } catch {
496+ files[path] = "gone";
497+ }
498+ count++;
499+ }
500+ return files;
501+}
502+
503+/**
504+ * What a shell command wrote, from the uncommitted files before it and
505+ * after: each one new since, or changed since. One it committed or put
506+ * back is not a write. Others writing in the same working copy during the
507+ * command are counted too, but only then, never what they wrote before.
508+ */
509+export function shellChanges(before, after) {
510+ if (!before || !after) return [];
511+ return Object.keys(after).filter((path) => before[path] !== after[path]);
512+}
513+
514+const roots = new Map();
515+/** The top of the working copy `cwd` is in, asked once per directory; null outside one. */
516+function repoRoot(cwd) {
517+ if (!cwd) return null;
518+ if (!roots.has(cwd)) roots.set(cwd, git(cwd, ["rev-parse", "--show-toplevel"]) || null);
519+ return roots.get(cwd);
520+}
521+
409522 /** The g1t CLI on PATH, without running it. G1T_BIN names another. */
410523 export function findCli(env = process.env) {
411524 if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null;
429542 encoding: "utf8",
430543 windowsHide: true,
431544 ...options,
545+ // The hooks say what this session wrote (see shellChanges); the CLI's
546+ // own guess, every uncommitted file in the working copy, would claim
547+ // what other sessions sharing it wrote too.
548+ env: { ...(options?.env ?? process.env), G1T_TOUCH: "0" },
432549 });
433550 }
434551
482599 turn: join(dir, `${safe}.turn`),
483600 titled: join(dir, `${safe}.titled`),
484601 task: (key) => join(dir, `${safe}.task.${safeName(key)}.json`),
602+ shell: (toolUseId) => join(dir, `${safe}.shell.${safeName(toolUseId)}.json`),
485603 unrecorded: join(dir, `${safe}.unrecorded.json`),
486604 agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`),
487605 };
528646 write(files.task(toolUseId || `${now}-${randomUUID()}`), JSON.stringify({
529647 tool_use_id: toolUseId || null,
530648 type: input?.subagent_type || null,
531− task: input?.description ? clip(input.description, TASK_LIMIT) : null,
532− prompt: input?.prompt ? clip(input.prompt, AGENT_PROMPT_LIMIT) : null,
649+ task: input?.description ? scrub(input.description, TASK_LIMIT) : null,
650+ prompt: input?.prompt ? scrub(input.prompt, AGENT_PROMPT_LIMIT) : null,
533651 key: input?.prompt ? promptKey(input.prompt) : null,
534652 at: now,
535653 }));
637755 evidence = subagentEvidence(paths);
638756 }
639757 const task = takeTask(files, { type: agent.type, toolUseId: evidence.toolUseId, prompt: evidence.prompt });
640− const what = task?.task ?? (evidence.description ? clip(evidence.description, TASK_LIMIT) : null);
641− const prompt = task?.prompt ?? (evidence.prompt ? clip(evidence.prompt, AGENT_PROMPT_LIMIT) : null);
758+ const what = task?.task ?? (evidence.description ? scrub(evidence.description, TASK_LIMIT) : null);
759+ const prompt = task?.prompt ?? (evidence.prompt ? scrub(evidence.prompt, AGENT_PROMPT_LIMIT) : null);
642760 if (what) agent.task = what;
643761 if (prompt && env.G1T_RECORD_PROMPTS !== "0") agent.prompt = prompt;
644762 return agent;
810928 // command, so a command before this (rare: it is the first second)
811929 // uses the working copy's remembered session.
812930 try {
813− writeFileSync(job.envFile, `export G1T_SESSION=${id}\n`, { flag: "a" });
931+ // G1T_TOUCH=0: they say what they wrote through the hooks, as cli() does.
932+ writeFileSync(job.envFile, `export G1T_SESSION=${id}\nexport G1T_TOUCH=0\n`, { flag: "a" });
814933 } catch {}
815934 }
816935 }
10421161 const kept = readJson(files.unrecorded, {}) ?? {};
10431162 const output = sessionStart(event, env, findCli(env));
10441163 if (!output) {
1045− const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(event.prompt) : null;
1164+ const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(redact(event.prompt)) : null;
10461165 if (intent) write(files.unrecorded, JSON.stringify({ ...kept, title: intent }));
10471166 return null;
10481167 }
10561175 const name = event?.hook_event_name;
10571176 if (name === "PreToolUse") {
10581177 if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null };
1178+ // The uncommitted files as a shell command finds them, so PostToolUse can say which it wrote.
1179+ if (SHELL_TOOLS.has(event.tool_name) && event.tool_use_id) {
1180+ const before = dirtyFiles(event.cwd || process.cwd());
1181+ if (before) write(sessionFiles(event.session_id, env).shell(event.tool_use_id), JSON.stringify(before));
1182+ }
10591183 const output = preToolUse(event, env);
10601184 const context = updateContext(event, env, { throttle: true });
10611185 if (!context) return { output, job: null };

Comments

Sign in to comment.