#!/usr/bin/env bun /** * Generate SKILL.md files from .tmpl templates. * * Pipeline: * read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md * * Supports --dry-run: generate to memory, exit 1 if different from committed file. * Used by skill:check and CI freshness checks. */ import { discoverTemplates, discoverSectionTemplates, includesSkill } from './discover-skills'; import { generateLlmsTxt } from './gen-llms-txt'; import { generateAgentsDigest, DIGEST_RELPATH, DIGEST_BYTE_BUDGET } from './gen-agents-digest'; import { generateDesignChecklistMd } from './resolvers/design-checklist'; import { DOM_DUMP_SCRIPT, DOM_DUMP_FILE } from '../lib/dom-dump-script'; import * as fs from 'fs'; import * as path from 'path'; import type { Host, TemplateContext } from './resolvers/types'; import { HOST_PATHS } from './resolvers/types'; import { RESOLVERS } from './resolvers/index'; import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index'; import type { HostConfig } from './host-config'; const ROOT = path.resolve(import.meta.dir, '..'); import { ALL_MODEL_NAMES, resolveModel, type Model } from './models'; type HostArg = Host | 'all'; /** Internal render settings. Inputs always come from ROOT; output routing and * content links are separate so checks can render canonical bytes into scratch. */ export interface GenerationOptions { host?: HostArg; dryRun?: boolean; outputRoot?: string; contentLinkRoot?: string | null; model?: Model | null; catalogMode?: 'trim' | 'full'; explainLevel?: 'default' | 'terse'; respectDetection?: boolean; log?: (message: string) => void; } interface RenderOptions { outputRoot: string; contentLinkRoot: string | null; model: Model | null; catalogMode: 'trim' | 'full'; explainLevel: 'default' | 'terse'; gbrainDetected: boolean; } export interface GeneratedArtifact { relativePath: string; kind: 'skill' | 'section' | 'metadata' | 'openclaw' | 'index' | 'digest' | 'asset'; host?: Host; } export interface GenerationDiagnostic { kind: 'stale' | 'error' | 'warning' | 'skipped'; message: string; host?: Host; relativePath?: string; } export interface GenerationResult { exitCode: 0 | 1; artifacts: GeneratedArtifact[]; diagnostics: GenerationDiagnostic[]; } /** Canonical generation never reads local detection state unless opted in. */ function loadGbrainOverride(respectDetection: boolean): boolean { if (!respectDetection) return false; const stateDir = process.env.GSTACK_HOME || path.join(process.env.HOME || '', '.gstack'); try { const json = JSON.parse(fs.readFileSync(path.join(stateDir, 'gbrain-detection.json'), 'utf-8')); // Slow, remote, and locked engines are still usable (#1964/#2051/#2456). return ['ok', 'timeout', 'thin-client', 'engine-locked'].includes(json.gbrain_local_status ?? ''); } catch { return false; } } function effectiveSuppressedResolvers(hostConfig: HostConfig, options: RenderOptions): Set { let list = hostConfig.suppressedResolvers || []; if (options.gbrainDetected) { list = list.filter(r => r !== 'GBRAIN_CONTEXT_LOAD' && r !== 'GBRAIN_SAVE_RESULTS'); } return new Set(list); } /** Parse CLI settings only when executing, never when imported by tests/checks. */ function parseGenerationArgs(args: string[]): GenerationOptions { const value = (flag: string): string | undefined => { const index = args.findIndex(arg => arg === flag || arg.startsWith(`${flag}=`)); if (index < 0) return undefined; const arg = args[index]; const result = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : args[index + 1]; if (!result || result.startsWith('--')) throw new Error(`${flag} requires a value`); return result; }; const hostValue = value('--host') ?? 'claude'; const host = hostValue === 'all' ? 'all' : resolveHostArg(hostValue) as Host; const modelValue = value('--model'); const model = modelValue === undefined ? null : resolveModel(modelValue); if (modelValue !== undefined && !model) { throw new Error(`Unknown model: ${modelValue}. Use ${ALL_MODEL_NAMES.join(', ')}, or a family variant (e.g., claude-opus-4-7, gpt-5.4-mini, o3).`); } const catalogMode = value('--catalog-mode') ?? 'trim'; if (catalogMode !== 'trim' && catalogMode !== 'full') { throw new Error(`Unknown catalog mode: ${catalogMode}. Use 'trim' (default) or 'full'.`); } const explainLevel = value('--explain-level') ?? 'default'; if (explainLevel !== 'default' && explainLevel !== 'terse') { throw new Error(`Unknown explain level: ${explainLevel}. Use 'default' or 'terse'.`); } const outDir = value('--out-dir'); const linkRoot = value('--link-root'); // Swap-in callers use --link-root for the FINAL serving path (#2692). // Direct --out-dir callers retain their existing links into the render. return { host, model, catalogMode, explainLevel, dryRun: args.includes('--dry-run'), respectDetection: args.includes('--respect-detection'), outputRoot: outDir === undefined ? ROOT : path.resolve(outDir), contentLinkRoot: linkRoot !== undefined ? path.resolve(linkRoot) : outDir !== undefined ? path.resolve(outDir) : null, }; } /** Repoint only Claude section links, retaining global bin/browse/doc paths. */ function rewriteSectionBase(content: string, linkRoot: string | null): string { if (!linkRoot) return content; // Callback replacement preserves literal $ sequences in configured paths. return content.replace( /~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g, (_m, p1: string) => `${linkRoot}/${p1}`, ); } // HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8) // Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS) // live in ./resolvers/constants and are consumed by resolvers directly. // ─── External Host Helpers ─────────────────────────────────── // Canonical implementation (the codex-helpers.ts shadow copy was deleted — // it was imported, immediately shadowed by this declaration, and stale) // Accepts optional frontmatter name to support directory/invocation name divergence function externalSkillName(skillDir: string, frontmatterName?: string): string { // Root skill (skillDir === '' or '.') always maps to 'gstack' regardless of frontmatter if (skillDir === '.' || skillDir === '') return 'gstack'; // Use frontmatter name when it differs from directory name (e.g., run-tests/ with name: test) const baseName = frontmatterName && frontmatterName !== skillDir ? frontmatterName : skillDir; // Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade) if (baseName.startsWith('gstack-')) return baseName; return `gstack-${baseName}`; } export function extractNameAndDescription(content: string): { name: string; description: string } { const fmStart = content.indexOf('---\n'); if (fmStart !== 0) return { name: '', description: '' }; const fmEnd = content.indexOf('\n---', fmStart + 4); if (fmEnd === -1) return { name: '', description: '' }; const frontmatter = content.slice(fmStart + 4, fmEnd); const nameMatch = frontmatter.match(/^name:\s*(.+)$/m); const name = nameMatch ? nameMatch[1].trim() : ''; let description = ''; const lines = frontmatter.split('\n'); let inDescription = false; const descLines: string[] = []; for (const line of lines) { if (line.match(/^description:\s*\|?\s*$/)) { inDescription = true; continue; } if (line.match(/^description:\s*\S/)) { description = line.replace(/^description:\s*/, '').trim(); break; } if (inDescription) { if (line === '' || line.match(/^\s/)) { descLines.push(line.replace(/^ /, '')); } else { break; } } } if (descLines.length > 0) { description = descLines.join('\n').trim(); } return { name, description }; } // ─── Voice Trigger Processing ──────────────────────────────── /** * Extract voice-triggers YAML list from frontmatter. * Returns an array of trigger strings, or [] if no voice-triggers field. */ function extractVoiceTriggers(content: string): string[] { const fmStart = content.indexOf('---\n'); if (fmStart !== 0) return []; const fmEnd = content.indexOf('\n---', fmStart + 4); if (fmEnd === -1) return []; const frontmatter = content.slice(fmStart + 4, fmEnd); const triggers: string[] = []; let inVoice = false; for (const line of frontmatter.split('\n')) { if (/^voice-triggers:/.test(line)) { inVoice = true; continue; } if (inVoice) { const m = line.match(/^\s+-\s+"(.+)"$/); if (m) triggers.push(m[1]); else if (!/^\s/.test(line)) break; } } return triggers; } /** * Preprocess voice triggers: fold voice-triggers YAML field into description, * then strip the field from frontmatter. Must run BEFORE transformFrontmatter * and extractNameAndDescription so all hosts see the updated description. */ function processVoiceTriggers(content: string): string { const triggers = extractVoiceTriggers(content); if (triggers.length === 0) return content; // Strip voice-triggers block from frontmatter content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, ''); // Get current description (after stripping voice-triggers, so it's clean) const { description } = extractNameAndDescription(content); if (!description) return content; // Build new description with voice triggers appended const voiceLine = `Voice triggers (speech-to-text aliases): ${triggers.map(t => `"${t}"`).join(', ')}.`; const newDescription = description + '\n' + voiceLine; // Replace old indented description with new in frontmatter const oldIndented = description.split('\n').map(l => ` ${l}`).join('\n'); const newIndented = newDescription.split('\n').map(l => ` ${l}`).join('\n'); content = content.replace(oldIndented, newIndented); return content; } // Export for testing export { extractVoiceTriggers, processVoiceTriggers }; // ─── Catalog Trim (v1.45.0.0 T4) ───────────────────────────── // // Frontmatter `description:` blocks today pack: a one-line outcome, "Use when // asked to..." voice triggers, "Proactively..." routing guidance, and a // "(gstack)" tag. This pile is the always-loaded catalog surface — every // session pays for the full text. The catalog trim splits the description // into a one-line catalog entry (lead sentence + "(gstack)") that stays in // the frontmatter, and a "## When to invoke" body section that holds the // routing/voice triggers prose for in-skill discovery. // // Opt-out: `--catalog-mode=full` keeps v1.44 behavior (no trim, full // description in frontmatter). Use when debugging routing regressions or // when shipping skills to hosts that depend on the legacy fat catalog. export interface CatalogParts { lead: string; // First sentence — kept in catalog routingProse: string; // "Use when asked to...", "Proactively..." paragraphs voiceLine: string | null; // "Voice triggers (speech-to-text aliases): ..." line if present hasGstackTag: boolean; } export function splitCatalogDescription(description: string): CatalogParts { // Voice triggers line (folded in by processVoiceTriggers earlier) const voiceMatch = description.match(/Voice triggers \(speech-to-text aliases\):[^\n]+/); const voiceLine = voiceMatch ? voiceMatch[0] : null; let working = voiceLine ? description.replace(voiceLine, '').trim() : description.trim(); const hasGstackTag = /\(gstack\)/.test(working); if (hasGstackTag) working = working.replace(/\(gstack\)/, '').trim(); // Lead = first sentence, ending at the first `.`/`!`/`?` that is followed by // whitespace or end-of-text. Terminator chars NOT followed by whitespace/end // (embedded periods in "TODOS.md", URLs, "v1.45.0.0") are consumed by the // second alternative `[.!?](?!\s|$)` and do NOT end the sentence. The two // alternatives are disjoint character classes, so there is no ambiguity and // no catastrophic-backtracking risk. If no terminator-followed-by-boundary // exists at all, we fall back to a 20-word cut below. // First normalize to single-line for sentence detection, then back out. const collapsed = working.replace(/\s+/g, ' ').trim(); const sentenceMatch = collapsed.match(/^((?:[^.!?]|[.!?](?!\s|$))*[.!?])(?:\s|$)/); // sentenceLead is the FULL first sentence (no truncation). We compute routing // from this position, then optionally truncate the displayed lead afterwards. // Truncating first then computing routing was the v1.45.0.0 bug — when the // first sentence exceeded 200 chars, the routing extraction would lose the // entire tail of the description (design-consultation's "Use when..." // routing prose silently dropped). const sentenceLead = sentenceMatch ? sentenceMatch[1].trim() : collapsed.split(/\s/).slice(0, 20).join(' '); // Routing prose: everything AFTER the first sentence boundary in the collapsed view. const leadInCollapsed = collapsed.indexOf(sentenceLead); const routingCollapsed = leadInCollapsed >= 0 ? collapsed.slice(leadInCollapsed + sentenceLead.length).trim() : ''; // Now produce the displayed lead — truncated if too long. The original // sentenceLead is preserved for routing extraction below. let lead = sentenceLead; if (lead.length > 200) { const trunc = lead.slice(0, 197); const lastSpace = trunc.lastIndexOf(' '); lead = (lastSpace > 60 ? trunc.slice(0, lastSpace) : trunc) + '...'; } // Restore line breaks for routing prose by mapping back to original layout. // Use original whitespace structure where possible; fall back to collapsed. // Anchor recovery on sentenceLead (the untruncated first sentence) — not // `lead` (which may have a "..." suffix and won't substring-match `working`). let routingProse = routingCollapsed; const collapsedLeadIdx = working.replace(/\s+/g, ' ').indexOf(sentenceLead); if (collapsedLeadIdx >= 0) { let consumed = 0; let cut = 0; for (let i = 0; i < working.length && consumed < collapsedLeadIdx + sentenceLead.length; i++) { if (/\s/.test(working[i])) { if (i === 0 || /\s/.test(working[i - 1])) continue; consumed += 1; } else { consumed += 1; } cut = i + 1; } const tail = working.slice(cut).trim(); if (tail.length > 0) routingProse = tail; } return { lead, routingProse, voiceLine, hasGstackTag }; } /** Build the catalog-trimmed `description:` block. */ export function buildTrimmedDescription(parts: CatalogParts): string { const lead = parts.lead.trim(); const suffix = parts.hasGstackTag ? ' (gstack)' : ''; return `${lead}${suffix}`; } /** Build the body section that holds the routing/voice prose. */ export function buildWhenToInvokeSection(parts: CatalogParts): string { const lines: string[] = ['## When to invoke this skill', '']; if (parts.routingProse) { lines.push(parts.routingProse); lines.push(''); } if (parts.voiceLine) { lines.push(parts.voiceLine); lines.push(''); } return lines.join('\n'); } /** * Render a string as a YAML inline scalar value (the text after `key: `), * quoting only when a plain scalar would be invalid or ambiguous. * * The bug this guards (#1778): a description like "Ship workflow: detect..." * emitted as a plain scalar has an interior ": " that a strict YAML parser * (Codex/OpenAI skill loading) reads as a nested mapping and rejects with * "mapping values are not allowed in this context". When quoting is needed we * fall back to JSON.stringify, which produces a double-quoted scalar that YAML * accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that * are already valid plain scalars pass through unchanged to keep regen diffs small. */ export function toYamlInlineScalar(s: string): string { const needsQuote = s.length === 0 || s !== s.trim() || // leading/trailing whitespace /:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity /\s#/.test(s) || // " #" → inline comment /\.\.\./.test(s) || // "..." → document-end marker; strict parsers reject mid-scalar (catalog-trim truncation appends it) /^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char return needsQuote ? JSON.stringify(s) : s; } /** * Apply catalog trim to a SKILL.md body: * - shorten frontmatter `description:` to lead + (gstack) * - insert "## When to invoke" body section AFTER the generated header * (so it lands near the top of body content, where routing guidance * belongs) * * Returns the rewritten content plus the extracted parts. */ export function applyCatalogTrim(content: string, skillName: string): { content: string; parts: CatalogParts } | null { // Locate description block in frontmatter if (!content.startsWith('---\n')) return null; const fmEnd = content.indexOf('\n---', 4); if (fmEnd === -1) return null; const frontmatter = content.slice(4, fmEnd); // Match `description: |` block + indented body lines const descMatch = frontmatter.match(/^description:\s*\|?\s*\n((?:\s{2,}.*(?:\n|$))+)/m) || frontmatter.match(/^description:\s+(.+)$/m); if (!descMatch) return null; // Extract full description text let descText: string; if (descMatch[0].startsWith('description: |') || /^description:\s*\|/.test(descMatch[0])) { descText = descMatch[1].split('\n').map(l => l.replace(/^\s{2}/, '')).join('\n').trim(); } else { descText = descMatch[1].trim(); } // Skip skills with very short descriptions (already trimmed or no routing prose). // Below ~120 chars, splitting adds no value. if (descText.length < 120) return null; const parts = splitCatalogDescription(descText); // If lead + (gstack) is already most of the text, no trim needed. const trimmedLen = buildTrimmedDescription(parts).length; if (trimmedLen >= descText.length - 20) return null; // Replace description in frontmatter — keep trailing newline so the next // YAML field doesn't collide on the same line as the description value. // Quote the value when it would be an invalid YAML plain scalar (the common // case: an interior ": " like "Ship workflow: detect..." which a strict YAML // parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar // only quotes when needed, so descriptions without special chars stay plain. const newDesc = buildTrimmedDescription(parts); // Function replacer (not a string) so a `$` in the description — e.g. a future // skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement // pattern and silently corrupt the frontmatter. const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`; const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine); let newContent = '---\n' + newFrontmatter + content.slice(fmEnd); // Insert body section after frontmatter (after the closing ---\n and any // existing GENERATED header). We insert before the first non-comment line. const bodyStart = newContent.indexOf('\n---\n') + 5; const whenToInvoke = '\n' + buildWhenToInvokeSection(parts).trim() + '\n'; // Skip past the generated header if present (it lives after frontmatter close) const headerMatch = newContent.slice(bodyStart).match(/^(\s*\n)+/); const insertAt = bodyStart + (headerMatch ? headerMatch[0].length : 0); newContent = newContent.slice(0, insertAt) + whenToInvoke + '\n' + newContent.slice(insertAt); return { content: newContent, parts }; } const OPENAI_SHORT_DESCRIPTION_LIMIT = 120; function condenseOpenAIShortDescription(description: string): string { const firstParagraph = description.split(/\n\s*\n/)[0] || description; const collapsed = firstParagraph.replace(/\s+/g, ' ').trim(); if (collapsed.length <= OPENAI_SHORT_DESCRIPTION_LIMIT) return collapsed; const truncated = collapsed.slice(0, OPENAI_SHORT_DESCRIPTION_LIMIT - 3); const lastSpace = truncated.lastIndexOf(' '); const safe = lastSpace > 40 ? truncated.slice(0, lastSpace) : truncated; return `${safe}...`; } function generateOpenAIYaml(displayName: string, shortDescription: string): string { return `interface: display_name: ${JSON.stringify(displayName)} short_description: ${JSON.stringify(shortDescription)} default_prompt: ${JSON.stringify(`Use ${displayName} for this task.`)} policy: allow_implicit_invocation: true `; } /** * Transform frontmatter for external hosts. * Claude: strips `sensitive:` field (only Factory uses it). * Codex: keeps name + description only, enforces 1024-char limit. * Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation. */ function transformFrontmatter(content: string, host: Host): string { const hostConfig = getHostConfig(host); const fm = hostConfig.frontmatter; if (fm.mode === 'denylist') { // Denylist mode: strip listed fields, keep everything else for (const field of fm.stripFields || []) { if (field === 'voice-triggers') { content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, ''); } else { content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), ''); } } return content; } // Allowlist mode: reconstruct frontmatter with only allowed fields const fmStart = content.indexOf('---\n'); if (fmStart !== 0) return content; const fmEnd = content.indexOf('\n---', fmStart + 4); if (fmEnd === -1) return content; const frontmatter = content.slice(fmStart + 4, fmEnd); const body = content.slice(fmEnd + 4); const { name, description } = extractNameAndDescription(content); // Description limit enforcement if (fm.descriptionLimit) { const behavior = fm.descriptionLimitBehavior || 'error'; if (description.length > fm.descriptionLimit) { if (behavior === 'error') { throw new Error( `${hostConfig.displayName} description for "${name}" is ${description.length} chars (max ${fm.descriptionLimit}). ` + `Compress the description in the .tmpl file.` ); } else if (behavior === 'warn') { console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`); } // 'truncate' — silently proceed } } // Build frontmatter with allowed fields const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n'); let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`; // Add extra fields (host-wide) if (fm.extraFields) { for (const [key, value] of Object.entries(fm.extraFields)) { if (key !== 'name' && key !== 'description') { newFm += `${key}: ${value}\n`; } } } // Add conditional fields if (fm.conditionalFields) { for (const rule of fm.conditionalFields) { const match = Object.entries(rule.if).every(([k, v]) => new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter) ); if (match) { for (const [key, value] of Object.entries(rule.add)) { newFm += `${key}: ${value}\n`; } } } } // Preserve additional keepFields beyond name and description if (fm.keepFields) { for (const field of fm.keepFields) { if (field === 'name' || field === 'description') continue; // Match YAML field with possible multi-line/array value (indented lines after colon) const fieldMatch = frontmatter.match(new RegExp(`^${field}:(.*(?:\\n(?:[ \\t]+.+))*)`, 'm')); if (fieldMatch) { newFm += `${field}:${fieldMatch[1]}\n`; } } } // Rename fields (copy values from template frontmatter with new keys) if (fm.renameFields) { for (const [oldName, newName] of Object.entries(fm.renameFields)) { const fieldMatch = frontmatter.match(new RegExp(`^${oldName}:(.+(?:\\n(?:\\s+.+)*)?)`, 'm')); if (fieldMatch) { newFm += `${newName}:${fieldMatch[1]}\n`; } } } newFm += '---'; return newFm + body; } /** * Extract hook descriptions from frontmatter for inline safety prose. * Returns a description of what the hooks do, or null if no hooks. */ function extractHookSafetyProse(tmplContent: string): string | null { if (!tmplContent.match(/^hooks:/m)) return null; // Parse the hook matchers to build a human-readable safety description const matchers: string[] = []; const matcherRegex = /matcher:\s*"(\w+)"/g; let m; while ((m = matcherRegex.exec(tmplContent)) !== null) { if (!matchers.includes(m[1])) matchers.push(m[1]); } if (matchers.length === 0) return null; // Build safety prose based on what tools are hooked const toolDescriptions: Record = { Bash: 'check bash commands for destructive operations (rm -rf, DROP TABLE, force-push, git reset --hard, etc.) before execution', Edit: 'verify file edits are within the allowed scope boundary before applying', Write: 'verify file writes are within the allowed scope boundary before applying', }; const safetyChecks = matchers .map(t => toolDescriptions[t] || `check ${t} operations for safety`) .join(', and '); return `> **Safety Advisory:** This skill includes safety checks that ${safetyChecks}. When using this skill, always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`; } // ─── External Host Config (now derived from hosts/*.ts) ────── // EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts // ─── Template Processing ──────────────────────────────────── const GENERATED_HEADER = `\n\n`; /** * Apply a host's configured path + tool rewrites. Extracted so both SKILL.md * (via processExternalHost) and section files (via processSectionTemplate) get * identical per-host treatment — a section's cross-references must rewrite the * same way the parent skill's do, or external hosts get wrong paths. */ function applyHostRewrites(content: string, hostConfig: HostConfig): string { let result = content; for (const rewrite of hostConfig.pathRewrites) { result = result.replaceAll(rewrite.from, rewrite.to); } if (hostConfig.toolRewrites) { for (const [from, to] of Object.entries(hostConfig.toolRewrites)) { result = result.replaceAll(from, to); } } return result; } /** * Resolve {{PLACEHOLDER}} / {{NAME:arg}} tokens against the RESOLVERS registry, * honoring host suppression and appliesTo gating, then assert nothing is left * unresolved. Extracted so SKILL.md and section templates resolve through the * exact same path — a security/sanitization fix to one can't miss the other. */ /** * A second {{PREAMBLE}} in one template re-expands the entire ~12K-token * preamble mid-document (#2508/#2362 — a PROSE mention of the macro in * spec/SKILL.md.tmpl expanded it a second time, +43KB per /spec load). * Resolution is context-blind, so any second occurrence — code fence, prose, * anywhere — is a generation error, never intentional. Throw at render time * so the mistake cannot reach a generated SKILL.md again. */ export function assertSinglePreamble(tmplContent: string, relTmplPath: string): void { const count = (tmplContent.match(/\{\{PREAMBLE\}\}/g) || []).length; if (count > 1) { throw new Error( `${relTmplPath} contains {{PREAMBLE}} ${count} times — a template may reference it ` + `at most once (each occurrence expands the full preamble; see #2508/#2362). ` + `Refer to "the preamble" in prose instead of the macro.`, ); } } function resolvePlaceholders( tmplContent: string, ctx: TemplateContext, hostConfig: HostConfig, relTmplPath: string, options: RenderOptions, ): string { assertSinglePreamble(tmplContent, relTmplPath); // effectiveSuppressedResolvers() honors --respect-detection: when gbrain is // detected locally, GBRAIN_* resolvers un-suppress. Shared by SKILL.md and // section generation so both paths get the same gbrain-aware behavior. const suppressed = effectiveSuppressedResolvers(hostConfig, options); const onePass = (input: string): string => input.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => { const parts = fullKey.split(':'); const resolverName = parts[0]; const args = parts.slice(1); if (suppressed.has(resolverName)) return ''; const resolve = RESOLVERS[resolverName]; if (!resolve) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`); return args.length > 0 ? resolve(ctx, args) : resolve(ctx); }); // Multi-pass: a resolver may emit content that itself contains {{TOKENS}} — the // {{SECTION:id}} resolver inlines a section template (with its own resolvers) // for non-Claude hosts. .replace() doesn't re-scan inserted text, so loop until // the output stabilizes. Bounded to avoid an infinite loop if a resolver ever // emits its own placeholder; 6 passes is far more nesting than any skill needs. let content = tmplContent; for (let pass = 0; pass < 6; pass++) { const next = onePass(content); if (next === content) break; content = next; } const remaining = content.match(/\{\{(\w+(?::[^}]+)?)\}\}/g); if (remaining) { throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`); } return content; } /** * Build the TemplateContext from a template's frontmatter. Shared by SKILL.md * and section generation so sections inherit the SAME context the parent skill * resolves with (skillName, tier, benefitsFrom, interactive) — enforced by * test/template-context-parity.test.ts. skillNameOverride lets section * generation pin the parent skill's name instead of deriving "sections". */ function buildContext( tmplContent: string, tmplPath: string, host: Host, options: RenderOptions, skillNameOverride?: string, ): TemplateContext { const { name: extractedName } = extractNameAndDescription(tmplContent); const skillName = skillNameOverride || extractedName || path.basename(path.dirname(tmplPath)); const benefitsMatch = tmplContent.match(/^benefits-from:\s*\[([^\]]*)\]/m); const benefitsFrom = benefitsMatch ? benefitsMatch[1].split(',').map(s => s.trim()).filter(Boolean) : undefined; const tierMatch = tmplContent.match(/^preamble-tier:\s*(\d+)$/m); const preambleTier = tierMatch ? parseInt(tierMatch[1], 10) : undefined; const interactiveMatch = tmplContent.match(/^interactive:\s*(true|false)\s*$/m); const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined; return { skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host], preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, }; } /** * Process external host output: routing, frontmatter, path rewrites, metadata. * Shared between Codex and Factory (and future external hosts). */ function processExternalHost( content: string, tmplContent: string, host: Host, skillDir: string, extractedDescription: string, ctx: TemplateContext, options: RenderOptions, frontmatterName?: string, ): { content: string; outputPath: string; symlinkLoop: boolean; metadata?: { outputPath: string; content: string } } { const hostConfig = getHostConfig(host); const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName); // --out-dir mirrors the host tree (outputs only; inputs read from ROOT). const outputDir = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills', name); const outputPath = path.join(outputDir, 'SKILL.md'); // Guard against symlink loops let symlinkLoop = false; const claudePath = ctx.tmplPath.replace(/\.tmpl$/, ''); try { const resolvedClaude = fs.realpathSync(claudePath); const resolvedExternal = path.join(fs.realpathSync(path.dirname(outputPath)), path.basename(outputPath)); if (resolvedClaude === resolvedExternal) { symlinkLoop = true; } } catch { // realpathSync fails if file doesn't exist yet — no symlink loop } // Extract hook safety prose BEFORE transforming frontmatter (which strips hooks) const safetyProse = extractHookSafetyProse(tmplContent); // Transform frontmatter (host-aware) let result = transformFrontmatter(content, host); // Insert safety advisory at the top of the body (after frontmatter) if (safetyProse) { const bodyStart = result.indexOf('\n---') + 4; result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart); } // Config-driven path + tool rewrites (shared with processSectionTemplate so // section cross-references get the same per-host treatment as SKILL.md). result = applyHostRewrites(result, hostConfig); // Config-driven: generate metadata (e.g., openai.yaml for Codex) const metadata = hostConfig.generation.generateMetadata && !symlinkLoop ? { outputPath: path.join(outputDir, 'agents', 'openai.yaml'), content: generateOpenAIYaml(name, condenseOpenAIShortDescription(extractedDescription)), } : undefined; return { content: result, outputPath, symlinkLoop, metadata }; } function processTemplate(tmplPath: string, host: Host, options: RenderOptions): { outputPath: string; content: string; symlinkLoop?: boolean; metadata?: { outputPath: string; content: string } } { // Normalize to LF at the entry point. Templates may have CRLF on disk when // checked out on Windows with core.autocrlf=true. Downstream regexes // (processVoiceTriggers, transformFrontmatter) hardcode \n, so without // normalization they silently no-op on CRLF — producing different output // than CI (Linux, LF) and breaking the Skill Docs Freshness check. // (catalogParts left the return type with the proactive-suggestions // retirement — merge of the two v1.64 waves.) const tmplContent = fs.readFileSync(tmplPath, 'utf-8').replace(/\r\n/g, '\n'); const relTmplPath = path.relative(ROOT, tmplPath); let outputPath = tmplPath.replace(/\.tmpl$/, ''); // Determine skill directory relative to ROOT const skillDir = path.relative(ROOT, path.dirname(tmplPath)); // --out-dir: mirror the skill tree into the out-dir instead of writing in // place (external hosts compute their own output paths below). if (host === 'claude') { outputPath = path.join(options.outputRoot, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, '')); } // Extract name/description: name drives external skill naming + setup symlinks // (and TemplateContext.skillName via buildContext); description feeds external // host metadata. When frontmatter name: differs from directory name (e.g. // run-tests/ with name: test), the frontmatter name wins. const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent); const currentHostConfig = getHostConfig(host); const ctx = buildContext(tmplContent, tmplPath, host, options); const skillName = ctx.skillName; // Replace placeholders + assert none remain (shared path with section generation). let content = resolvePlaceholders(tmplContent, ctx, currentHostConfig, relTmplPath, options); // Preprocess voice triggers: fold into description, strip field from frontmatter. // Must run BEFORE transformFrontmatter so all hosts see the updated description, // and BEFORE extractedDescription is used by external host metadata. content = processVoiceTriggers(content); // Re-extract description AFTER voice trigger preprocessing so Codex openai.yaml // metadata gets the updated description with voice triggers included. const postProcessDescription = extractNameAndDescription(content).description; // For Claude: strip sensitive: field (only Factory uses it) // For external hosts: route output, transform frontmatter, rewrite paths let symlinkLoop = false; let metadata: { outputPath: string; content: string } | undefined; if (host === 'claude') { content = transformFrontmatter(content, host); } else { const result = processExternalHost(content, tmplContent, host, skillDir, postProcessDescription, ctx, options, extractedName || undefined); content = result.content; outputPath = result.outputPath; symlinkLoop = result.symlinkLoop; metadata = result.metadata; } // Prepend generated header (after frontmatter) const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath)); const fmEnd = content.indexOf('---', content.indexOf('---') + 3); if (fmEnd !== -1) { const insertAt = content.indexOf('\n', fmEnd) + 1; content = content.slice(0, insertAt) + header + content.slice(insertAt); } else { content = header + content; } // Catalog trim (Claude only — external hosts have their own frontmatter shapes) if (host === 'claude' && options.catalogMode === 'trim') { const trimmed = applyCatalogTrim(content, skillName); if (trimmed) content = trimmed.content; } // --out-dir: repoint section-base paths to the out-dir (no-op otherwise). if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot); return { outputPath, content, symlinkLoop, metadata }; } /** * Generate one on-demand section file (`/sections/.md.tmpl` → * `.md`). Sections are BODY FRAGMENTS — no frontmatter, no catalog trim, * no voice triggers. They resolve placeholders through the SAME path as * SKILL.md (resolvePlaceholders) using the PARENT skill's TemplateContext * (so appliesTo gating + tier behave identically — a section's {{PREAMBLE}}- * style resolver renders the same content it would in the parent, not empty). * * Output routing mirrors SKILL.md: Claude writes in-tree at * `/sections/.md`; external hosts write to * `/skills//sections/.md`. External hosts get * applyHostRewrites so cross-references resolve per host. */ function processSectionTemplate( sectionTmplPath: string, skillDir: string, host: Host, options: RenderOptions, ): { outputPath: string; content: string } { const tmplContent = fs.readFileSync(sectionTmplPath, 'utf-8'); const relTmplPath = path.relative(ROOT, sectionTmplPath); const hostConfig = getHostConfig(host); // Read the owning SKILL.md.tmpl so the section inherits the parent's name + // tier + benefits-from (TemplateContext parity). Fall back to the dir name. const parentTmplPath = path.join(ROOT, skillDir, 'SKILL.md.tmpl'); const parentContent = fs.existsSync(parentTmplPath) ? fs.readFileSync(parentTmplPath, 'utf-8') : ''; const parentName = (parentContent && extractNameAndDescription(parentContent).name) || skillDir; const ctx = buildContext(parentContent || tmplContent, parentTmplPath, host, options, parentName); // Resolve placeholders against the section body (shared guard catches stragglers). let content = resolvePlaceholders(tmplContent, ctx, hostConfig, relTmplPath, options); // External hosts: rewrite cross-reference paths/tools (no frontmatter to transform). if (host !== 'claude') { content = applyHostRewrites(content, hostConfig); } else { // --out-dir: a section may cross-reference another section by absolute path; // repoint those to the out-dir too (no-op when --out-dir is unset). content = rewriteSectionBase(content, options.contentLinkRoot); } // Plain generated header (no frontmatter to insert after). content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content; const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, ''); let outputPath: string; if (host === 'claude') { outputPath = path.join(options.outputRoot, skillDir, 'sections', fileName); } else { const externalName = externalSkillName(skillDir, parentName); outputPath = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName); } return { outputPath, content }; } // ─── Main ─────────────────────────────────────────────────── /** Render each artifact once. Artifact writes go through emit(); stale external * caches are pruned only after a successful host render. Dry runs use the same * inventory as normal generation, including metadata and * shared outputs. Options are per invocation so imports/concurrent runs cannot * inherit another caller's model, detection, or output paths. * * templates + host settings -> render -> emit -> dry-run: compare only * | -> normal: mkdir + write * shared index/digest ---------------+ -> artifact inventory + diagnostics * successful external host -----------------> normal only: prune retired caches */ export async function runGeneration(settings: GenerationOptions = {}): Promise { const options: RenderOptions = { outputRoot: path.resolve(settings.outputRoot ?? ROOT), contentLinkRoot: settings.contentLinkRoot ?? null, model: settings.model ?? null, catalogMode: settings.catalogMode ?? 'trim', explainLevel: settings.explainLevel ?? 'default', gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false), }; const hosts = settings.host === 'all' ? ALL_HOST_NAMES as Host[] : [settings.host ?? 'claude']; const log = settings.log ?? (() => {}); const artifacts: GeneratedArtifact[] = []; const diagnostics: GenerationDiagnostic[] = []; const templates = discoverTemplates(ROOT); const sections = discoverSectionTemplates(ROOT); const rel = (outputPath: string) => path.relative(options.outputRoot, outputPath).split(path.sep).join('/'); function emit(outputPath: string, content: string, kind: GeneratedArtifact['kind'], host?: Host): void { const relativePath = rel(outputPath); artifacts.push({ relativePath, kind, ...(host ? { host } : {}) }); try { if (settings.dryRun) { let existing: string | undefined; try { existing = fs.readFileSync(outputPath, 'utf-8'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; // Windows reports ENOENT for a child of a regular file. Distinguish // that filesystem error from a missing artifact without writing. let parent = path.dirname(outputPath); while (true) { try { if (!fs.statSync(parent).isDirectory()) { throw Object.assign(new Error(`ENOTDIR: output ancestor is not a directory: ${parent}`, { cause: error }), { code: 'ENOTDIR' }); } break; } catch (ancestorError) { if ((ancestorError as NodeJS.ErrnoException).code !== 'ENOENT') throw ancestorError; const next = path.dirname(parent); if (next === parent) throw error; parent = next; } } } if (existing !== content) { diagnostics.push({ kind: 'stale', relativePath, host, message: `STALE: ${relativePath}` }); log(`STALE: ${relativePath}`); } else { log(`FRESH: ${relativePath}`); } } else { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, content); log(`GENERATED: ${relativePath}`); } } catch (error) { throw Object.assign(new Error(`${relativePath}: ${(error as Error).message}`), { relativePath }); } } function failed(error: unknown, host?: Host): void { const message = error instanceof Error ? error.message : String(error); diagnostics.push({ kind: 'error', host, message, relativePath: (error as { relativePath?: string })?.relativePath }); } for (const host of hosts) { try { const hostConfig = getHostConfig(host); const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = []; const renderedNames = new Set(); for (const template of templates) { const skillDir = path.dirname(template.tmpl); if (!includesSkill(hostConfig, skillDir)) continue; const result = processTemplate(path.join(ROOT, template.tmpl), host, options); const relativePath = rel(result.outputPath); if (host !== 'claude') renderedNames.add(path.basename(path.dirname(result.outputPath))); if (result.symlinkLoop) { diagnostics.push({ kind: 'skipped', relativePath, host, message: `SKIPPED (symlink loop): ${relativePath}` }); log(`SKIPPED (symlink loop): ${relativePath}`); continue; } emit(result.outputPath, result.content, 'skill', host); if (result.metadata) emit(result.metadata.outputPath, result.metadata.content, 'metadata', host); tokenBudget.push({ skill: relativePath, lines: result.content.split('\n').length, tokens: Math.round(result.content.length / 4) }); const TOKEN_CEILING_BYTES = 160_000; if (result.content.length > TOKEN_CEILING_BYTES) { const message = `⚠️ TOKEN CEILING: ${relativePath} is ${result.content.length} bytes (~${Math.round(result.content.length / 4)} tokens), exceeds ${TOKEN_CEILING_BYTES} byte ceiling (~40K tokens)`; diagnostics.push({ kind: 'warning', host, relativePath, message }); } } // Claude carves sections; every external host inlines these templates. for (const section of host === 'claude' ? sections : []) { if (!includesSkill(hostConfig, section.skillDir)) continue; const result = processSectionTemplate(path.join(ROOT, section.tmpl), section.skillDir, host, options); emit(result.outputPath, result.content, 'section', host); tokenBudget.push({ skill: rel(result.outputPath), lines: result.content.split('\n').length, tokens: Math.round(result.content.length / 4) }); } // Claude owns these catalog-derived runtime assets. Use the same host // inclusion rule and compare-or-write path as every other artifact. if (host === 'claude' && includesSkill(hostConfig, 'review')) { emit(path.join(options.outputRoot, 'review', 'design-checklist.md'), generateDesignChecklistMd(), 'asset', host); emit(path.join(options.outputRoot, DOM_DUMP_FILE), DOM_DUMP_SCRIPT + '\n', 'asset', host); } if (host === 'openclaw') { for (const variant of ['lite', 'full', 'plan'] as const) { const fileName = `gstack-${variant}-CLAUDE.md`; emit(path.join(options.outputRoot, 'openclaw', fileName), fs.readFileSync(path.join(ROOT, 'openclaw', 'templates', fileName), 'utf-8'), 'openclaw', host); } } // A failed render exits this try before pruning: its inventory is partial. // Only remove generated directories owned by this host; sidecars and user // skills survive. Dry runs never create, rewrite, or remove any directory. if (!settings.dryRun && host !== 'claude') { const skillsRoot = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills'); let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(skillsRoot, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } for (const entry of entries) { if (entry.isSymbolicLink() || !entry.isDirectory() || !entry.name.startsWith('gstack-') || renderedNames.has(entry.name)) continue; // Keep the old render usable until setup has migrated installed copies/links. if (entry.name === 'gstack-claude' && process.env.GSTACK_DEFER_CLAUDE_RENAME_PRUNE === '1') continue; let generated = false; try { generated = fs.readFileSync(path.join(skillsRoot, entry.name, 'SKILL.md'), 'utf-8').includes('