Skip to content
419 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.

Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how.1/**
2 * The skill format (docs.g1t.sh/guides/agent-skills/, "The skill format"):
3 * a skill is a folder holding `SKILL.md`, whose YAML front-matter names it
4 * and says when to use it, followed by the instructions, plus optional
5 * files: `scripts/`, `resources/` (or `references/` and `assets/`, as other
6 * tools write them). It is the open `SKILL.md` format, so a skill written
7 * elsewhere imports as it is.
8 *
9 * g1t reads two extra front-matter keys:
10 *
11 * - `tools:` the agent tools the skill uses (a list, or names separated by
12 * commas). Only tools agents have are accepted, and naming one never
13 * gives it to an agent: an agent without it is told that part doesn't
14 * work where it is.
15 * - `requires_computer:` true when the skill needs the agent's own
16 * computer. A skill with files in `scripts/` needs one whatever it says.
17 *
18 * Other keys (`license`, `metadata`, `allowed-tools`, ...) are kept as they
19 * are and change nothing.
20 *
21 * Pure and standalone (no value imports), so services and the site share it
22 * and Node runs its tests on the file as it is.
23 */
24import type { AgentSkill } from "./skills";
25
26/** The most one skill's folder holds, every file together: 1 MB. */
27export const SKILL_FOLDER_MAX_BYTES = 1024 * 1024;
28/** The most skills from the library one agent has, however they are attached. */
29export const SKILLS_PER_AGENT_MAX = 100;
30/** The most files in one skill's folder, `SKILL.md` included. */
31export const SKILL_FILES_MAX = 200;
32/** The longest name: lowercase letters, digits and hyphens. */
33export const SKILL_NAME_MAX = 64;
34/** The longest description ("when to use it"). */
35export const SKILL_DESCRIPTION_MAX = 1024;
36/** Where a repository keeps the skills it mirrors: `.g1t/skills/<name>/SKILL.md`. */
37export const SKILLS_REPO_DIR = ".g1t/skills";
38
39/**
40 * Names the library can't use: g1t's foundational skills', so `use_skill`
41 * always means one thing. The same list as `FOUNDATIONAL_SKILL_IDS`
42 * (a test keeps them equal).
43 */
44export const RESERVED_SKILL_NAMES: readonly string[] = ["documents", "research", "data", "code", "communication", "files"];
45
46/** The tools a skill may name in `tools:`, grouped as the editor shows them. The agents service's tool box has exactly these (a test there checks). */
47export const AGENT_TOOL_GROUPS: { group: string; tools: string[] }[] = [
48 { group: "Code", tools: ["list_repositories", "search_code", "read_file", "list_issues", "get_issue", "get_pull", "recent_activity", "draft_issue", "comment", "review_pull"] },
49 { group: "Artifacts and files", tools: ["search_artifacts", "read_artifact", "list_spaces", "stale_artifacts", "create_artifact", "edit_artifact", "share_artifact", "make_file"] },
50 { group: "Chat", tools: ["search_messages", "read_thread", "workspace_roster"] },
51 { group: "Teamwork", tools: ["ask_colleague", "hand_off", "start_session", "post_update", "use_subagent", "bring_in", "use_skill"] },
52 { group: "Memory", tools: ["remember", "forget"] },
Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.53 { group: "Outside g1t", tools: ["lookup_outside", "import_outside", "act_outside", "request_ability"] },
Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how.54];
55
56/** Every tool a skill may name. */
57export const AGENT_TOOL_NAMES: readonly string[] = AGENT_TOOL_GROUPS.flatMap((group) => group.tools);
58
59/** One file in a skill's folder: text as it is, anything else as standard base64. */
60export type SkillFile = { path: string; content: string; encoding?: "utf8" | "base64" };
61
62/** A skill folder that passed every check. */
63export type CheckedSkill = {
64 name: string;
65 /** When to use it, from the front-matter. */
66 description: string;
67 tools: string[];
68 /** Said in the front-matter, or it has scripts. */
69 requires_computer: boolean;
70 /** The instructions: SKILL.md after its front-matter. */
71 body: string;
72 /** SKILL.md as written. */
73 skill_md: string;
74 /** Every other file, by path. */
75 files: SkillFile[];
76 /** The files under `scripts/`. */
77 scripts: string[];
78 /** Every file's bytes together. */
79 bytes: number;
80 /** Front-matter keys g1t doesn't read, kept as they are. */
81 extra: Record<string, unknown>;
82};
83
84export type SkillCheck = { ok: true; skill: CheckedSkill } | { ok: false; message: string };
85
86// ── Front-matter ─────────────────────────────────────────────────────────
87
88/** SKILL.md split into its front-matter's text and the body after it. */
89export function splitFrontMatter(text: string): { ok: true; yaml: string; body: string } | { ok: false; message: string } {
90 const normal = text.replace(/^/, "").replace(/\r\n?/g, "\n");
91 if (!normal.startsWith("---\n")) return { ok: false, message: "SKILL.md starts with front-matter: a line of ---, then name: and description:, then another line of ---." };
92 const end = normal.indexOf("\n---", 3);
93 if (end < 0) return { ok: false, message: "SKILL.md's front-matter has no closing line of ---." };
94 const after = normal.slice(end + 4);
95 if (after && !after.startsWith("\n") && !/^-*\s*(\n|$)/.test(after)) return { ok: false, message: "SKILL.md's front-matter has no closing line of ---." };
96 return { ok: true, yaml: normal.slice(4, end + 1), body: after.replace(/^-*[ \t]*\n?/, "") };
97}
98
99type Line = { indent: number; text: string; no: number };
100
101function unquote(raw: string, no: number): unknown {
102 const value = raw.trim();
103 if (value.startsWith('"')) {
104 try {
105 const end = closingQuote(value, '"');
106 if (end < 0) throw new Error();
107 return JSON.parse(value.slice(0, end + 1).replace(/\\'/g, "'"));
108 } catch {
109 throw new Error(`line ${no}: a double-quoted value isn't closed`);
110 }
111 }
112 if (value.startsWith("'")) {
113 const end = closingQuote(value, "'");
114 if (end < 0) throw new Error(`line ${no}: a single-quoted value isn't closed`);
115 return value.slice(1, end).replace(/''/g, "'");
116 }
117 if (value.startsWith("[")) {
118 if (!value.endsWith("]")) throw new Error(`line ${no}: a list in [ ] isn't closed`);
119 const inner = value.slice(1, -1).trim();
120 if (!inner) return [];
121 return splitFlow(inner).map((item) => unquote(item, no));
122 }
123 const plain = value.replace(/\s+#.*$/, "");
124 if (plain === "" || plain === "~" || plain === "null") return null;
125 if (plain === "true" || plain === "True") return true;
126 if (plain === "false" || plain === "False") return false;
127 if (/^-?\d+(\.\d+)?$/.test(plain)) return Number(plain);
128 return plain;
129}
130
131/** Where a quoted value starting at 0 closes, or -1. */
132function closingQuote(value: string, quote: string): number {
133 for (let i = 1; i < value.length; i++) {
134 if (quote === '"' && value[i] === "\\") {
135 i++;
136 continue;
137 }
138 if (value[i] === quote) {
139 if (quote === "'" && value[i + 1] === "'") {
140 i++;
141 continue;
142 }
143 return i;
144 }
145 }
146 return -1;
147}
148
149/** `a, "b, c", d` into its items. */
150function splitFlow(inner: string): string[] {
151 const items: string[] = [];
152 let current = "";
153 let quote: string | null = null;
154 for (let i = 0; i < inner.length; i++) {
155 const c = inner[i]!;
156 if (quote) {
157 current += c;
158 if (c === "\\" && quote === '"') current += inner[++i] ?? "";
159 else if (c === quote) quote = null;
160 } else if (c === '"' || c === "'") {
161 quote = c;
162 current += c;
163 } else if (c === ",") {
164 items.push(current.trim());
165 current = "";
166 } else current += c;
167 }
168 if (current.trim()) items.push(current.trim());
169 return items;
170}
171
172/** A block scalar (`|` or `>`) from the lines under its key. */
173function blockScalar(style: string, lines: Line[]): string {
174 if (!lines.length) return "";
175 const base = Math.min(...lines.filter((l) => l.text.trim()).map((l) => l.indent));
176 const texts = lines.map((l) => (l.text.trim() ? " ".repeat(Math.max(0, l.indent - base)) + l.text.trimEnd() : ""));
177 let out: string;
178 if (style.startsWith("|")) out = texts.join("\n");
179 else {
180 out = "";
181 for (const t of texts) {
182 if (!t) out += "\n";
183 else out += out && !out.endsWith("\n") ? ` ${t}` : t;
184 }
185 }
186 if (style.includes("-")) return out.replace(/\n+$/, "");
187 return `${out.replace(/\n+$/, "")}\n`;
188}
189
190/**
191 * The front-matter as values: the YAML that SKILL.md files use, which is
192 * keys with plain, quoted or block (`|`, `>`) strings, booleans, numbers,
193 * lists (`[a, b]` or `- a` lines) and one level of nested keys
194 * (`metadata:`). Throws with the line that can't be read.
195 */
196export function parseFrontMatter(yaml: string): Record<string, unknown> {
197 const lines: Line[] = yaml.split("\n").map((raw, i) => ({ indent: raw.length - raw.trimStart().length, text: raw.trimStart(), no: i + 2 }));
198 return readMap(lines, 0, lines.length, 0);
199}
200
201function readMap(lines: Line[], from: number, to: number, indent: number): Record<string, unknown> {
202 const out: Record<string, unknown> = {};
203 let i = from;
204 while (i < to) {
205 const line = lines[i]!;
206 if (!line.text || line.text.startsWith("#")) {
207 i++;
208 continue;
209 }
210 if (line.indent !== indent) throw new Error(`line ${line.no}: unexpected indentation`);
211 const match = line.text.match(/^("[^"]+"|'[^']+'|[A-Za-z0-9_.\-]+)\s*:(?:\s+(.*)|\s*)$/);
212 if (!match) throw new Error(`line ${line.no}: expected "key: value"`);
213 const key = match[1]!.replace(/^["']|["']$/g, "");
214 const rest = (match[2] ?? "").trim();
215 // The lines that belong to this key: more indented than it, or blank.
216 let j = i + 1;
217 while (j < to && (!lines[j]!.text || lines[j]!.indent > indent || (lines[j]!.indent === indent && lines[j]!.text.startsWith("- ") && !rest))) j++;
218 // Trailing blank lines belong to the next key.
219 let end = j;
220 while (end > i + 1 && !lines[end - 1]!.text) end--;
221 const child = lines.slice(i + 1, end);
222 if (key in out) throw new Error(`line ${line.no}: ${key} is given twice`);
223 if (/^[|>][+-]?$/.test(rest)) out[key] = blockScalar(rest, child);
224 else if (rest && !rest.startsWith("#")) {
225 const value = unquote(rest, line.no);
226 const more = child.filter((l) => l.text && !l.text.startsWith("#"));
227 // A plain value folded over more lines.
228 if (more.length && typeof value === "string" && !/^["'[]/.test(rest)) out[key] = [value, ...more.map((l) => l.text.trim())].join(" ");
229 else if (more.length) throw new Error(`line ${more[0]!.no}: unexpected indentation`);
230 else out[key] = value;
231 } else {
232 const content = child.filter((l) => l.text && !l.text.startsWith("#"));
233 if (!content.length) out[key] = null;
234 else if (content[0]!.text.startsWith("- ") || content[0]!.text === "-") {
235 out[key] = content.map((l) => {
236 if (!l.text.startsWith("-")) throw new Error(`line ${l.no}: expected "- item"`);
237 return unquote(l.text.slice(1), l.no);
238 });
239 } else {
240 const start = lines.indexOf(content[0]!);
241 out[key] = readMap(lines, start, end, content[0]!.indent);
242 }
243 }
244 i = j;
245 }
246 return out;
247}
248
249/** A value as YAML on one line: plain when that reads the same, else double-quoted. */
250function yamlScalar(value: unknown): string {
251 if (value === null || value === undefined) return "null";
252 if (typeof value === "boolean" || typeof value === "number") return String(value);
253 const text = String(value);
254 if (/^[A-Za-z0-9_][A-Za-z0-9_ ,.;()/'’&+-]*$/.test(text) && !/^(true|false|null|yes|no|~|-?\d+(\.\d+)?)$/i.test(text) && !/\s$/.test(text)) return text;
255 return JSON.stringify(text);
256}
257
258function yamlValue(value: unknown, indent: string): string {
259 if (Array.isArray(value)) return value.length ? `[${value.map(yamlScalar).join(", ")}]` : "[]";
260 if (value && typeof value === "object") {
261 const entries = Object.entries(value as Record<string, unknown>);
262 return `\n${entries.map(([k, v]) => `${indent} ${k}:${keyed(v, `${indent} `)}`).join("\n")}`;
263 }
264 return yamlScalar(value);
265}
266
267/** What follows `key:`: a space and the value, or the nested keys on their own lines. */
268function keyed(value: unknown, indent: string): string {
269 const nested = !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
270 return nested ? yamlValue(value, indent) : ` ${yamlValue(value && typeof value === "object" && !Array.isArray(value) ? null : value, indent)}`;
271}
272
273/** SKILL.md for a skill written in the editor: front-matter (name, description, then g1t's keys and any kept ones), then the instructions. */
274export function renderSkillMd(input: { name: string; description: string; tools?: string[]; requires_computer?: boolean; body: string; extra?: Record<string, unknown> }): string {
275 const lines = ["---", `name: ${input.name}`, `description: ${yamlScalar(input.description.replace(/\s*\n\s*/g, " ").trim())}`];
276 if (input.tools?.length) lines.push(`tools: ${yamlValue(input.tools, "")}`);
277 if (input.requires_computer) lines.push("requires_computer: true");
278 for (const [key, value] of Object.entries(input.extra ?? {})) {
279 if (["name", "description", "tools", "requires_computer"].includes(key)) continue;
280 lines.push(`${key}:${keyed(value, "")}`);
281 }
282 lines.push("---", "", input.body.replace(/\r\n?/g, "\n").trim(), "");
283 return lines.join("\n");
284}
285
286// ── Checks ───────────────────────────────────────────────────────────────
287
288/** Why `name` can't name a skill, or null. */
289export function skillNameProblem(name: string): string | null {
290 if (!name) return "A skill needs a name.";
291 if (name.length > SKILL_NAME_MAX) return `A skill's name is at most ${SKILL_NAME_MAX} characters.`;
292 if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return "A skill's name is lowercase letters, digits and single hyphens, like release-notes.";
293 if (RESERVED_SKILL_NAMES.includes(name)) return `${name} is one of g1t's foundational skills. Choose another name.`;
294 return null;
295}
296
297/** Whether `path` is a plain relative path inside the folder. */
298export function skillPathProblem(path: string): string | null {
299 if (!path || path.length > 255) return `${path || "A file"} isn't a path a skill can hold.`;
300 if (path.startsWith("/") || path.includes("\\") || /[\u0000-\u001f]/.test(path)) return `${path} isn't a path a skill can hold.`;
301 const parts = path.split("/");
302 if (parts.some((part) => !part || part === "." || part === ".." || part === ".git")) return `${path} isn't a path a skill can hold.`;
303 return null;
304}
305
306/** A file's size in bytes. */
307export function skillFileBytes(file: SkillFile): number {
308 if (file.encoding === "base64") {
309 const clean = file.content.replace(/\s+/g, "");
310 return Math.floor((clean.length * 3) / 4) - (clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0);
311 }
312 return new TextEncoder().encode(file.content).length;
313}
314
315/** "12 KB". */
316export function skillSize(bytes: number): string {
317 if (bytes < 1024) return `${bytes} B`;
318 if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
319 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
320}
321
322function listOf(value: unknown): string[] | null {
323 if (value === null || value === undefined) return [];
324 if (Array.isArray(value)) return value.every((v) => typeof v === "string") ? (value as string[]) : null;
325 if (typeof value === "string") return value.split(/[\s,]+/).filter(Boolean);
326 return null;
327}
328
329/**
330 * Checks a skill's folder: one `SKILL.md` at its top with a name and a
331 * description, tools agents have, plain paths, at most `SKILL_FILES_MAX`
332 * files and `SKILL_FOLDER_MAX_BYTES` together. `expectName` is the
333 * folder's name when it must match (a repository's `.g1t/skills/<name>/`).
334 */
335export function checkSkillFolder(input: SkillFile[], options: { expectName?: string | null } = {}): SkillCheck {
336 const bad = (message: string): SkillCheck => ({ ok: false, message });
337 if (!Array.isArray(input) || !input.length) return bad("A skill is a folder with a SKILL.md in it.");
338 if (input.length > SKILL_FILES_MAX) return bad(`A skill holds at most ${SKILL_FILES_MAX} files; this one has ${input.length}.`);
339 const seen = new Set<string>();
340 let skillMd: SkillFile | null = null;
341 const files: SkillFile[] = [];
342 let bytes = 0;
343 for (const raw of input) {
344 if (!raw || typeof raw.path !== "string" || typeof raw.content !== "string") return bad("Each file needs a path and its content.");
345 const path = raw.path.replace(/^\.\//, "");
346 const problem = skillPathProblem(path);
347 if (problem) return bad(problem);
348 if (seen.has(path.toLowerCase())) return bad(`${path} is in the folder twice.`);
349 seen.add(path.toLowerCase());
350 const file: SkillFile = { path, content: raw.content, encoding: raw.encoding === "base64" ? "base64" : "utf8" };
351 if (file.encoding === "base64" && !/^[A-Za-z0-9+/\s]*=*\s*$/.test(file.content)) return bad(`${path} isn't valid base64.`);
352 bytes += skillFileBytes(file);
353 if (path.toLowerCase() === "skill.md") {
354 if (file.encoding === "base64") return bad("SKILL.md is text.");
355 skillMd = { ...file, path: "SKILL.md" };
356 } else files.push(file);
357 }
358 if (bytes > SKILL_FOLDER_MAX_BYTES) return bad(`A skill's folder is at most 1 MB; this one is ${skillSize(bytes)}.`);
359 if (!skillMd) {
360 const nested = files.find((f) => f.path.toLowerCase().endsWith("/skill.md"));
361 return bad(nested ? `SKILL.md belongs at the top of the folder, not in ${nested.path.slice(0, nested.path.lastIndexOf("/"))}.` : "A skill's folder needs a SKILL.md at its top.");
362 }
363 const split = splitFrontMatter(skillMd.content);
364 if (!split.ok) return bad(split.message);
365 let front: Record<string, unknown>;
366 try {
367 front = parseFrontMatter(split.yaml);
368 } catch (error) {
369 return bad(`SKILL.md's front-matter can't be read: ${error instanceof Error ? error.message : String(error)}.`);
370 }
371 const name = typeof front.name === "string" ? front.name.trim() : "";
372 const nameProblem = skillNameProblem(name);
373 if (nameProblem) return bad(name ? nameProblem : "SKILL.md's front-matter needs a name: the skill's name, like release-notes.");
374 if (options.expectName && options.expectName !== name) return bad(`The folder is ${options.expectName}, but its SKILL.md is named ${name}. They must match.`);
375 const description = typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim() : "";
376 if (!description) return bad("SKILL.md's front-matter needs a description: when an agent should use the skill.");
377 if (description.length > SKILL_DESCRIPTION_MAX) return bad(`A skill's description is at most ${SKILL_DESCRIPTION_MAX} characters.`);
378 const listed = listOf(front.tools);
379 if (!listed) return bad("tools: is a list of tool names, like [read_file, make_file].");
380 const tools = [...new Set(listed.map((t) => t.trim()))].filter(Boolean);
381 const unknown = tools.filter((tool) => !AGENT_TOOL_NAMES.includes(tool));
382 if (unknown.length) return bad(`tools: names ${unknown.join(", ")}, which ${unknown.length === 1 ? "isn't a tool" : "aren't tools"} agents have. Agents' tools are listed in the skills guide.`);
383 const flag = front.requires_computer;
384 if (flag !== undefined && flag !== null && typeof flag !== "boolean") return bad("requires_computer: is true or false.");
385 const body = split.body.trim();
386 if (!body) return bad("SKILL.md needs instructions under its front-matter.");
387 const scripts = files.filter((f) => f.path.startsWith("scripts/")).map((f) => f.path);
388 const extra: Record<string, unknown> = {};
389 for (const [key, value] of Object.entries(front)) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
390 files.sort((a, b) => a.path.localeCompare(b.path));
391 return {
392 ok: true,
393 skill: { name, description, tools, requires_computer: flag === true || scripts.length > 0, body, skill_md: skillMd.content, files, scripts, bytes, extra },
394 };
395}
396
397// ── g1t's foundational skills, as SKILL.md ───────────────────────────────
398
399/**
400 * A foundational skill written out in the same format: its name, when to
401 * use it, the tools its working parts use, then its playbook and, part by
402 * part, what works today and what is coming.
403 */
404export function foundationalSkillMd(skill: AgentSkill): string {
405 const ready = skill.abilities.filter((a) => a.status === "ready");
406 const coming = skill.abilities.filter((a) => a.status === "coming");
407 const tools = [...new Set(ready.flatMap((a) => a.tools))];
408 const body = [
409 `# ${skill.name}`,
410 "",
411 skill.instructions,
412 "",
413 "## What works today",
414 "",
415 ...ready.map((a) => `- **${a.label}**${a.tools.length ? ` (${a.tools.map((t) => `\`${t}\``).join(", ")})` : ""}: ${a.note}`),
416 ...(coming.length ? ["", "## Not yet in g1t", "", ...coming.map((a) => `- **${a.label}**: ${a.note}`)] : []),
417 ].join("\n");
418 return renderSkillMd({ name: skill.id, description: skill.when, tools, body, extra: { metadata: { source: "g1t", version: skill.version } } });
419}