Skip to content
421 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"] },
Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.54 // Its own computer (docs.g1t.sh/guides/agents/, "Its computer"): offered in sessions, as its abilities allow.
55 { group: "Its computer", tools: ["run_command", "computer_read_file", "computer_write_file"] },
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.56];
57
58/** Every tool a skill may name. */
59export const AGENT_TOOL_NAMES: readonly string[] = AGENT_TOOL_GROUPS.flatMap((group) => group.tools);
60
61/** One file in a skill's folder: text as it is, anything else as standard base64. */
62export type SkillFile = { path: string; content: string; encoding?: "utf8" | "base64" };
63
64/** A skill folder that passed every check. */
65export type CheckedSkill = {
66 name: string;
67 /** When to use it, from the front-matter. */
68 description: string;
69 tools: string[];
70 /** Said in the front-matter, or it has scripts. */
71 requires_computer: boolean;
72 /** The instructions: SKILL.md after its front-matter. */
73 body: string;
74 /** SKILL.md as written. */
75 skill_md: string;
76 /** Every other file, by path. */
77 files: SkillFile[];
78 /** The files under `scripts/`. */
79 scripts: string[];
80 /** Every file's bytes together. */
81 bytes: number;
82 /** Front-matter keys g1t doesn't read, kept as they are. */
83 extra: Record<string, unknown>;
84};
85
86export type SkillCheck = { ok: true; skill: CheckedSkill } | { ok: false; message: string };
87
88// ── Front-matter ─────────────────────────────────────────────────────────
89
90/** SKILL.md split into its front-matter's text and the body after it. */
91export function splitFrontMatter(text: string): { ok: true; yaml: string; body: string } | { ok: false; message: string } {
92 const normal = text.replace(/^/, "").replace(/\r\n?/g, "\n");
93 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 ---." };
94 const end = normal.indexOf("\n---", 3);
95 if (end < 0) return { ok: false, message: "SKILL.md's front-matter has no closing line of ---." };
96 const after = normal.slice(end + 4);
97 if (after && !after.startsWith("\n") && !/^-*\s*(\n|$)/.test(after)) return { ok: false, message: "SKILL.md's front-matter has no closing line of ---." };
98 return { ok: true, yaml: normal.slice(4, end + 1), body: after.replace(/^-*[ \t]*\n?/, "") };
99}
100
101type Line = { indent: number; text: string; no: number };
102
103function unquote(raw: string, no: number): unknown {
104 const value = raw.trim();
105 if (value.startsWith('"')) {
106 try {
107 const end = closingQuote(value, '"');
108 if (end < 0) throw new Error();
109 return JSON.parse(value.slice(0, end + 1).replace(/\\'/g, "'"));
110 } catch {
111 throw new Error(`line ${no}: a double-quoted value isn't closed`);
112 }
113 }
114 if (value.startsWith("'")) {
115 const end = closingQuote(value, "'");
116 if (end < 0) throw new Error(`line ${no}: a single-quoted value isn't closed`);
117 return value.slice(1, end).replace(/''/g, "'");
118 }
119 if (value.startsWith("[")) {
120 if (!value.endsWith("]")) throw new Error(`line ${no}: a list in [ ] isn't closed`);
121 const inner = value.slice(1, -1).trim();
122 if (!inner) return [];
123 return splitFlow(inner).map((item) => unquote(item, no));
124 }
125 const plain = value.replace(/\s+#.*$/, "");
126 if (plain === "" || plain === "~" || plain === "null") return null;
127 if (plain === "true" || plain === "True") return true;
128 if (plain === "false" || plain === "False") return false;
129 if (/^-?\d+(\.\d+)?$/.test(plain)) return Number(plain);
130 return plain;
131}
132
133/** Where a quoted value starting at 0 closes, or -1. */
134function closingQuote(value: string, quote: string): number {
135 for (let i = 1; i < value.length; i++) {
136 if (quote === '"' && value[i] === "\\") {
137 i++;
138 continue;
139 }
140 if (value[i] === quote) {
141 if (quote === "'" && value[i + 1] === "'") {
142 i++;
143 continue;
144 }
145 return i;
146 }
147 }
148 return -1;
149}
150
151/** `a, "b, c", d` into its items. */
152function splitFlow(inner: string): string[] {
153 const items: string[] = [];
154 let current = "";
155 let quote: string | null = null;
156 for (let i = 0; i < inner.length; i++) {
157 const c = inner[i]!;
158 if (quote) {
159 current += c;
160 if (c === "\\" && quote === '"') current += inner[++i] ?? "";
161 else if (c === quote) quote = null;
162 } else if (c === '"' || c === "'") {
163 quote = c;
164 current += c;
165 } else if (c === ",") {
166 items.push(current.trim());
167 current = "";
168 } else current += c;
169 }
170 if (current.trim()) items.push(current.trim());
171 return items;
172}
173
174/** A block scalar (`|` or `>`) from the lines under its key. */
175function blockScalar(style: string, lines: Line[]): string {
176 if (!lines.length) return "";
177 const base = Math.min(...lines.filter((l) => l.text.trim()).map((l) => l.indent));
178 const texts = lines.map((l) => (l.text.trim() ? " ".repeat(Math.max(0, l.indent - base)) + l.text.trimEnd() : ""));
179 let out: string;
180 if (style.startsWith("|")) out = texts.join("\n");
181 else {
182 out = "";
183 for (const t of texts) {
184 if (!t) out += "\n";
185 else out += out && !out.endsWith("\n") ? ` ${t}` : t;
186 }
187 }
188 if (style.includes("-")) return out.replace(/\n+$/, "");
189 return `${out.replace(/\n+$/, "")}\n`;
190}
191
192/**
193 * The front-matter as values: the YAML that SKILL.md files use, which is
194 * keys with plain, quoted or block (`|`, `>`) strings, booleans, numbers,
195 * lists (`[a, b]` or `- a` lines) and one level of nested keys
196 * (`metadata:`). Throws with the line that can't be read.
197 */
198export function parseFrontMatter(yaml: string): Record<string, unknown> {
199 const lines: Line[] = yaml.split("\n").map((raw, i) => ({ indent: raw.length - raw.trimStart().length, text: raw.trimStart(), no: i + 2 }));
200 return readMap(lines, 0, lines.length, 0);
201}
202
203function readMap(lines: Line[], from: number, to: number, indent: number): Record<string, unknown> {
204 const out: Record<string, unknown> = {};
205 let i = from;
206 while (i < to) {
207 const line = lines[i]!;
208 if (!line.text || line.text.startsWith("#")) {
209 i++;
210 continue;
211 }
212 if (line.indent !== indent) throw new Error(`line ${line.no}: unexpected indentation`);
213 const match = line.text.match(/^("[^"]+"|'[^']+'|[A-Za-z0-9_.\-]+)\s*:(?:\s+(.*)|\s*)$/);
214 if (!match) throw new Error(`line ${line.no}: expected "key: value"`);
215 const key = match[1]!.replace(/^["']|["']$/g, "");
216 const rest = (match[2] ?? "").trim();
217 // The lines that belong to this key: more indented than it, or blank.
218 let j = i + 1;
219 while (j < to && (!lines[j]!.text || lines[j]!.indent > indent || (lines[j]!.indent === indent && lines[j]!.text.startsWith("- ") && !rest))) j++;
220 // Trailing blank lines belong to the next key.
221 let end = j;
222 while (end > i + 1 && !lines[end - 1]!.text) end--;
223 const child = lines.slice(i + 1, end);
224 if (key in out) throw new Error(`line ${line.no}: ${key} is given twice`);
225 if (/^[|>][+-]?$/.test(rest)) out[key] = blockScalar(rest, child);
226 else if (rest && !rest.startsWith("#")) {
227 const value = unquote(rest, line.no);
228 const more = child.filter((l) => l.text && !l.text.startsWith("#"));
229 // A plain value folded over more lines.
230 if (more.length && typeof value === "string" && !/^["'[]/.test(rest)) out[key] = [value, ...more.map((l) => l.text.trim())].join(" ");
231 else if (more.length) throw new Error(`line ${more[0]!.no}: unexpected indentation`);
232 else out[key] = value;
233 } else {
234 const content = child.filter((l) => l.text && !l.text.startsWith("#"));
235 if (!content.length) out[key] = null;
236 else if (content[0]!.text.startsWith("- ") || content[0]!.text === "-") {
237 out[key] = content.map((l) => {
238 if (!l.text.startsWith("-")) throw new Error(`line ${l.no}: expected "- item"`);
239 return unquote(l.text.slice(1), l.no);
240 });
241 } else {
242 const start = lines.indexOf(content[0]!);
243 out[key] = readMap(lines, start, end, content[0]!.indent);
244 }
245 }
246 i = j;
247 }
248 return out;
249}
250
251/** A value as YAML on one line: plain when that reads the same, else double-quoted. */
252function yamlScalar(value: unknown): string {
253 if (value === null || value === undefined) return "null";
254 if (typeof value === "boolean" || typeof value === "number") return String(value);
255 const text = String(value);
256 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;
257 return JSON.stringify(text);
258}
259
260function yamlValue(value: unknown, indent: string): string {
261 if (Array.isArray(value)) return value.length ? `[${value.map(yamlScalar).join(", ")}]` : "[]";
262 if (value && typeof value === "object") {
263 const entries = Object.entries(value as Record<string, unknown>);
264 return `\n${entries.map(([k, v]) => `${indent} ${k}:${keyed(v, `${indent} `)}`).join("\n")}`;
265 }
266 return yamlScalar(value);
267}
268
269/** What follows `key:`: a space and the value, or the nested keys on their own lines. */
270function keyed(value: unknown, indent: string): string {
271 const nested = !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
272 return nested ? yamlValue(value, indent) : ` ${yamlValue(value && typeof value === "object" && !Array.isArray(value) ? null : value, indent)}`;
273}
274
275/** SKILL.md for a skill written in the editor: front-matter (name, description, then g1t's keys and any kept ones), then the instructions. */
276export function renderSkillMd(input: { name: string; description: string; tools?: string[]; requires_computer?: boolean; body: string; extra?: Record<string, unknown> }): string {
277 const lines = ["---", `name: ${input.name}`, `description: ${yamlScalar(input.description.replace(/\s*\n\s*/g, " ").trim())}`];
278 if (input.tools?.length) lines.push(`tools: ${yamlValue(input.tools, "")}`);
279 if (input.requires_computer) lines.push("requires_computer: true");
280 for (const [key, value] of Object.entries(input.extra ?? {})) {
281 if (["name", "description", "tools", "requires_computer"].includes(key)) continue;
282 lines.push(`${key}:${keyed(value, "")}`);
283 }
284 lines.push("---", "", input.body.replace(/\r\n?/g, "\n").trim(), "");
285 return lines.join("\n");
286}
287
288// ── Checks ───────────────────────────────────────────────────────────────
289
290/** Why `name` can't name a skill, or null. */
291export function skillNameProblem(name: string): string | null {
292 if (!name) return "A skill needs a name.";
293 if (name.length > SKILL_NAME_MAX) return `A skill's name is at most ${SKILL_NAME_MAX} characters.`;
294 if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return "A skill's name is lowercase letters, digits and single hyphens, like release-notes.";
295 if (RESERVED_SKILL_NAMES.includes(name)) return `${name} is one of g1t's foundational skills. Choose another name.`;
296 return null;
297}
298
299/** Whether `path` is a plain relative path inside the folder. */
300export function skillPathProblem(path: string): string | null {
301 if (!path || path.length > 255) return `${path || "A file"} isn't a path a skill can hold.`;
302 if (path.startsWith("/") || path.includes("\\") || /[\u0000-\u001f]/.test(path)) return `${path} isn't a path a skill can hold.`;
303 const parts = path.split("/");
304 if (parts.some((part) => !part || part === "." || part === ".." || part === ".git")) return `${path} isn't a path a skill can hold.`;
305 return null;
306}
307
308/** A file's size in bytes. */
309export function skillFileBytes(file: SkillFile): number {
310 if (file.encoding === "base64") {
311 const clean = file.content.replace(/\s+/g, "");
312 return Math.floor((clean.length * 3) / 4) - (clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0);
313 }
314 return new TextEncoder().encode(file.content).length;
315}
316
317/** "12 KB". */
318export function skillSize(bytes: number): string {
319 if (bytes < 1024) return `${bytes} B`;
320 if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
321 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
322}
323
324function listOf(value: unknown): string[] | null {
325 if (value === null || value === undefined) return [];
326 if (Array.isArray(value)) return value.every((v) => typeof v === "string") ? (value as string[]) : null;
327 if (typeof value === "string") return value.split(/[\s,]+/).filter(Boolean);
328 return null;
329}
330
331/**
332 * Checks a skill's folder: one `SKILL.md` at its top with a name and a
333 * description, tools agents have, plain paths, at most `SKILL_FILES_MAX`
334 * files and `SKILL_FOLDER_MAX_BYTES` together. `expectName` is the
335 * folder's name when it must match (a repository's `.g1t/skills/<name>/`).
336 */
337export function checkSkillFolder(input: SkillFile[], options: { expectName?: string | null } = {}): SkillCheck {
338 const bad = (message: string): SkillCheck => ({ ok: false, message });
339 if (!Array.isArray(input) || !input.length) return bad("A skill is a folder with a SKILL.md in it.");
340 if (input.length > SKILL_FILES_MAX) return bad(`A skill holds at most ${SKILL_FILES_MAX} files; this one has ${input.length}.`);
341 const seen = new Set<string>();
342 let skillMd: SkillFile | null = null;
343 const files: SkillFile[] = [];
344 let bytes = 0;
345 for (const raw of input) {
346 if (!raw || typeof raw.path !== "string" || typeof raw.content !== "string") return bad("Each file needs a path and its content.");
347 const path = raw.path.replace(/^\.\//, "");
348 const problem = skillPathProblem(path);
349 if (problem) return bad(problem);
350 if (seen.has(path.toLowerCase())) return bad(`${path} is in the folder twice.`);
351 seen.add(path.toLowerCase());
352 const file: SkillFile = { path, content: raw.content, encoding: raw.encoding === "base64" ? "base64" : "utf8" };
353 if (file.encoding === "base64" && !/^[A-Za-z0-9+/\s]*=*\s*$/.test(file.content)) return bad(`${path} isn't valid base64.`);
354 bytes += skillFileBytes(file);
355 if (path.toLowerCase() === "skill.md") {
356 if (file.encoding === "base64") return bad("SKILL.md is text.");
357 skillMd = { ...file, path: "SKILL.md" };
358 } else files.push(file);
359 }
360 if (bytes > SKILL_FOLDER_MAX_BYTES) return bad(`A skill's folder is at most 1 MB; this one is ${skillSize(bytes)}.`);
361 if (!skillMd) {
362 const nested = files.find((f) => f.path.toLowerCase().endsWith("/skill.md"));
363 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.");
364 }
365 const split = splitFrontMatter(skillMd.content);
366 if (!split.ok) return bad(split.message);
367 let front: Record<string, unknown>;
368 try {
369 front = parseFrontMatter(split.yaml);
370 } catch (error) {
371 return bad(`SKILL.md's front-matter can't be read: ${error instanceof Error ? error.message : String(error)}.`);
372 }
373 const name = typeof front.name === "string" ? front.name.trim() : "";
374 const nameProblem = skillNameProblem(name);
375 if (nameProblem) return bad(name ? nameProblem : "SKILL.md's front-matter needs a name: the skill's name, like release-notes.");
376 if (options.expectName && options.expectName !== name) return bad(`The folder is ${options.expectName}, but its SKILL.md is named ${name}. They must match.`);
377 const description = typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim() : "";
378 if (!description) return bad("SKILL.md's front-matter needs a description: when an agent should use the skill.");
379 if (description.length > SKILL_DESCRIPTION_MAX) return bad(`A skill's description is at most ${SKILL_DESCRIPTION_MAX} characters.`);
380 const listed = listOf(front.tools);
381 if (!listed) return bad("tools: is a list of tool names, like [read_file, make_file].");
382 const tools = [...new Set(listed.map((t) => t.trim()))].filter(Boolean);
383 const unknown = tools.filter((tool) => !AGENT_TOOL_NAMES.includes(tool));
384 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.`);
385 const flag = front.requires_computer;
386 if (flag !== undefined && flag !== null && typeof flag !== "boolean") return bad("requires_computer: is true or false.");
387 const body = split.body.trim();
388 if (!body) return bad("SKILL.md needs instructions under its front-matter.");
389 const scripts = files.filter((f) => f.path.startsWith("scripts/")).map((f) => f.path);
390 const extra: Record<string, unknown> = {};
391 for (const [key, value] of Object.entries(front)) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
392 files.sort((a, b) => a.path.localeCompare(b.path));
393 return {
394 ok: true,
395 skill: { name, description, tools, requires_computer: flag === true || scripts.length > 0, body, skill_md: skillMd.content, files, scripts, bytes, extra },
396 };
397}
398
399// ── g1t's foundational skills, as SKILL.md ───────────────────────────────
400
401/**
402 * A foundational skill written out in the same format: its name, when to
403 * use it, the tools its working parts use, then its playbook and, part by
404 * part, what works today and what is coming.
405 */
406export function foundationalSkillMd(skill: AgentSkill): string {
407 const ready = skill.abilities.filter((a) => a.status === "ready");
408 const coming = skill.abilities.filter((a) => a.status === "coming");
409 const tools = [...new Set(ready.flatMap((a) => a.tools))];
410 const body = [
411 `# ${skill.name}`,
412 "",
413 skill.instructions,
414 "",
415 "## What works today",
416 "",
417 ...ready.map((a) => `- **${a.label}**${a.tools.length ? ` (${a.tools.map((t) => `\`${t}\``).join(", ")})` : ""}: ${a.note}`),
418 ...(coming.length ? ["", "## Not yet in g1t", "", ...coming.map((a) => `- **${a.label}**: ${a.note}`)] : []),
419 ].join("\n");
420 return renderSkillMd({ name: skill.id, description: skill.when, tools, body, extra: { metadata: { source: "g1t", version: skill.version } } });
421}