gen-skill-docs.ts
1224 lines · scripts/gen-skill-docs.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/**
3 * Generate SKILL.md files from .tmpl templates.
4 *
5 * Pipeline:
6 * read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md
7 *
8 * Supports --dry-run: generate to memory, exit 1 if different from committed file.
9 * Used by skill:check and CI freshness checks.
10 */
12import { discoverTemplates, discoverSectionTemplates, includesSkill } from './discover-skills';
13import { externalSkillName, extractNameAndDescription } from './external-skill-names';
14export { extractNameAndDescription } from './external-skill-names';
15import { generateLlmsTxt } from './gen-llms-txt';
16import { generateAgentsDigest, DIGEST_RELPATH, DIGEST_BYTE_BUDGET } from './gen-agents-digest';
17import { DOCTOR_COMPONENTS_RELPATH } from './gen-doctor-components';
18import { renderDoctorComponentsSh } from '../lib/doctor-components';
19import { generateDesignChecklistMd } from './resolvers/design-checklist';
20import { DOM_DUMP_SCRIPT, DOM_DUMP_FILE } from '../lib/dom-dump-script';
21import * as fs from 'fs';
22import * as path from 'path';
23import type { Host, TemplateContext } from './resolvers/types';
24import { HOST_PATHS } from './resolvers/types';
25import { RESOLVERS } from './resolvers/index';
26import { applySectionGuards, rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections';
27import { ModuleError, excludedSkillDirs, selectModules } from './modules';
28import type { ModuleFilter } from './resolvers/types';
29import { insertRuntimePreludes } from './resolvers/runtime-root';
30import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist';
31import { extractVoiceTriggers } from './resolvers/voice-triggers';
32import { frontmatterList, withTriggerLead } from '../lib/skill-triggers';
33import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
34import type { HostConfig } from './host-config';
36const ROOT = path.resolve(import.meta.dir, '..');
37import { ALL_MODEL_NAMES, resolveModel, type Model } from './models';
38import { resolveStateRoot } from '../lib/state-root';
39import { mkdirpSync } from '../lib/fs-utils';
41type HostArg = Host | 'all';
43/** Internal render settings. Inputs always come from ROOT; output routing and
44 * content links are separate so checks can render canonical bytes into scratch. */
45export interface GenerationOptions {
46 host?: HostArg;
47 dryRun?: boolean;
48 outputRoot?: string;
49 contentLinkRoot?: string | null;
50 /** Install-context render contract: absolute install root this render serves. */
51 installRoot?: string | null;
52 /** Skills this install leaves unregistered (gstack-config disabled_skills); the router omits them. */
53 disabledSkills?: string[];
54 model?: Model | null;
55 catalogMode?: 'trim' | 'full';
56 explainLevel?: 'default' | 'terse';
57 respectDetection?: boolean;
58 log?: (message: string) => void;
59 planMode?: boolean;
60 /** Module loadout (scripts/modules.ts selectModules); null/undefined = `full`. */
61 modules?: ModuleFilter | null;
62}
64interface RenderOptions {
65 outputRoot: string;
66 contentLinkRoot: string | null;
67 installRoot: string | null;
68 disabledSkills: string[];
69 model: Model | null;
70 catalogMode: 'trim' | 'full';
71 explainLevel: 'default' | 'terse';
72 gbrainDetected: boolean;
73 planMode: boolean;
74 modules: ModuleFilter | null;
75}
77export interface GeneratedArtifact {
78 relativePath: string;
79 kind: 'skill' | 'section' | 'preamble' | 'metadata' | 'openclaw' | 'index' | 'digest' | 'doctor-components' | 'asset';
80 host?: Host;
81}
83export interface GenerationDiagnostic {
84 kind: 'stale' | 'error' | 'warning' | 'skipped';
85 message: string;
86 host?: Host;
87 relativePath?: string;
88}
90export interface GenerationResult {
91 exitCode: 0 | 1;
92 artifacts: GeneratedArtifact[];
93 diagnostics: GenerationDiagnostic[];
94}
96/** Canonical generation never reads local detection state unless opted in. */
97function loadGbrainOverride(respectDetection: boolean): boolean {
98 if (!respectDetection) return false;
99 const stateDir = resolveStateRoot();
100 try {
101 const json = JSON.parse(fs.readFileSync(path.join(stateDir, 'gbrain-detection.json'), 'utf-8'));
102 // Slow, remote, locked and briefly unreachable engines are still usable (#1964/#2051/#2456, A2).
103 return ['ok', 'timeout', 'db-unreachable', 'thin-client', 'engine-locked'].includes(json.gbrain_local_status ?? '');
104 } catch {
105 return false;
106 }
107}
109function effectiveSuppressedResolvers(hostConfig: HostConfig, options: RenderOptions): Set<string> {
110 let list = hostConfig.suppressedResolvers || [];
111 if (options.gbrainDetected) {
112 list = list.filter(r => r !== 'GBRAIN_CONTEXT_LOAD' && r !== 'GBRAIN_SAVE_RESULTS');
113 }
114 return new Set(list);
115}
117/** Parse CLI settings only when executing, never when imported by tests/checks. */
118function parseGenerationArgs(args: string[]): GenerationOptions {
119 const value = (flag: string): string | undefined => {
120 const index = args.findIndex(arg => arg === flag || arg.startsWith(`${flag}=`));
121 if (index < 0) return undefined;
122 const arg = args[index];
123 const result = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : args[index + 1];
124 if (!result || result.startsWith('--')) throw new Error(`${flag} requires a value`);
125 return result;
126 };
127 const hostValue = value('--host') ?? 'claude';
128 const host = hostValue === 'all' ? 'all' : resolveHostArg(hostValue) as Host;
129 const modelValue = value('--model');
130 const model = modelValue === undefined ? null : resolveModel(modelValue);
131 if (modelValue !== undefined && !model) {
132 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).`);
133 }
134 const catalogMode = value('--catalog-mode') ?? 'trim';
135 if (catalogMode !== 'trim' && catalogMode !== 'full') {
136 throw new Error(`Unknown catalog mode: ${catalogMode}. Use 'trim' (default) or 'full'.`);
137 }
138 const explainLevel = value('--explain-level') ?? 'default';
139 if (explainLevel !== 'default' && explainLevel !== 'terse') {
140 throw new Error(`Unknown explain level: ${explainLevel}. Use 'default' or 'terse'.`);
141 }
142 const planModeValue = value('--plan-mode');
143 if (planModeValue !== undefined && planModeValue !== 'on' && planModeValue !== 'off') {
144 throw new Error(`Unknown plan mode: ${planModeValue}. Use 'on' or 'off'.`);
145 }
146 const planMode = planModeValue === 'off' ? false : true;
147 const outDir = value('--out-dir');
148 const linkRoot = value('--link-root');
149 const disabledValue = value('--disabled-skills');
150 const disabledSkills = (disabledValue ?? '').split(/[\s,]+/).filter(Boolean);
151 const badDisabled = disabledSkills.find(n => !/^\/?[a-z0-9-]+$/.test(n));
152 if (badDisabled) throw new Error(`--disabled-skills takes skill names (got ${JSON.stringify(badDisabled)})`);
153 const modules = selectModules({ loadout: value('--loadout'), modules: value('--modules') });
154 if (modules && (outDir === undefined || path.resolve(outDir) === ROOT)) {
155 throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs an --out-dir outside the checkout root; the committed tree is always the full render`, 'bin/gstack-modules render --host <host> --modules <spec> --out-dir <dir>');
156 }
157 const installRoot = value('--install-root');
158 if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) {
159 throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`);
160 }
161 // Swap-in callers use --link-root for the FINAL serving path (#2692).
162 // Direct --out-dir callers retain their existing links into the render.
163 return {
164 host, model, catalogMode, explainLevel, planMode,
165 dryRun: args.includes('--dry-run'),
166 respectDetection: args.includes('--respect-detection'),
167 outputRoot: outDir === undefined ? ROOT : path.resolve(outDir),
168 contentLinkRoot: linkRoot !== undefined ? path.resolve(linkRoot)
169 : outDir !== undefined ? path.resolve(outDir) : null,
170 installRoot: installRoot ?? null,
171 disabledSkills,
172 modules,
173 };
174}
176/** A section file is left out when its skill or its manifest entry is excluded. */
177function excludesSection(filter: ModuleFilter | null, skillDir: string, tmpl: string): boolean {
178 if (!filter) return false;
179 if (filter.excluded.has(`skill:${skillDir}`)) return true;
180 const file = path.basename(tmpl).replace(/\.tmpl$/, '');
181 const manifest = path.join(ROOT, skillDir, 'sections', 'manifest.json');
182 if (!fs.existsSync(manifest)) return false;
183 const entry = (JSON.parse(fs.readFileSync(manifest, 'utf-8')).sections ?? []).find((s: { file: string }) => s.file === file);
184 return entry ? filter.excluded.has(`section:${skillDir}/${entry.id}`) : false;
185}
187/** Install roots are spliced into shell lines unquoted, so only plain absolute paths. */
188const INSTALL_ROOT_PATTERN = /^\/[A-Za-z0-9_.@+\/-]*$/;
190/**
191 * Install-context render contract (docs/ADDING_A_HOST.md): a per-install render
192 * names its own install root instead of the host's default global root. Null
193 * keeps the committed bytes.
194 */
195function rewriteInstallRoot(content: string, hostConfig: HostConfig, installRoot: string | null): string {
196 if (!installRoot) return content;
197 const root = installRoot.replace(/\/+$/, '');
198 const defaults = hostConfig.usesEnvVars
199 ? [`$HOME/${hostConfig.globalRoot}`, `~/${hostConfig.globalRoot}`]
200 : ['$HOME/.claude/skills/gstack', '~/.claude/skills/gstack'];
201 return defaults.reduce((text, from) => text.split(from).join(root), content);
202}
204/**
205 * C8 (#3018): the router names every skill, so it routed to skills the user
206 * disabled (it also said "When in doubt, invoke the skill" until v1.91.31). A per-install render that
207 * knows its disabled skills drops their routing rules (keeping the other side
208 * of an "A or B" rule) and says which skills are off. The router itself and
209 * the way back (gstack-upgrade) can never be disabled.
210 */
211function omitDisabledSkills(content: string, disabled: string[]): string {
212 const off = new Set(disabled.map(n => n.replace(/^\//, '')).filter(n => n !== 'gstack' && n !== 'gstack-upgrade').map(n => n.replace(/^gstack-/, '')));
213 if (off.size === 0) return content;
214 const isOff = (ref: string) => off.has(ref.replace(/^`\/(?:gstack-)?|`$/g, ''));
215 const routed = content.split('\n').flatMap(line => {
216 const m = line.match(/^(- .*→ invoke )(`\/[a-z0-9-]+`(?: or `\/[a-z0-9-]+`)*)(.*)$/);
217 if (!m) return [line];
218 const kept = m[2].split(' or ').filter(ref => !isOff(ref));
219 return kept.length ? [`${m[1]}${kept.join(' or ')}${m[3]}`] : [];
220 }).join('\n');
221 const names = [...off].map(n => `\`/${n}\``).join(', ');
222 return routed.replace('\n## Route first\n', `\n## Route first\n\nDisabled on this install (never invoke or suggest them): ${names}.\n`);
223}
225/** Repoint only Claude section links, retaining global bin/browse/doc paths. */
226function rewriteSectionBase(content: string, linkRoot: string | null): string {
227 if (!linkRoot) return content;
228 // Callback replacement preserves literal $ sequences in configured paths.
229 return content.replace(
230 /~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g,
231 (_m, p1: string) => `${linkRoot}/${p1}`,
232 );
233}
235// HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8)
236// Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS)
237// live in ./resolvers/constants and are consumed by resolvers directly.
239// ─── External Host Helpers ───────────────────────────────────
241// ─── Voice Trigger Processing ────────────────────────────────
243/**
244 * Preprocess voice triggers: fold voice-triggers YAML field into description,
245 * then strip the field from frontmatter. Must run BEFORE transformFrontmatter
246 * and extractNameAndDescription so all hosts see the updated description.
247 */
248function processVoiceTriggers(content: string): string {
249 const triggers = extractVoiceTriggers(content);
250 if (triggers.length === 0) return content;
252 // Strip voice-triggers block from frontmatter
253 content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
255 // Get current description (after stripping voice-triggers, so it's clean)
256 const { description } = extractNameAndDescription(content);
257 if (!description) return content;
259 // Build new description with voice triggers appended
260 const voiceLine = `Voice triggers (speech-to-text aliases): ${triggers.map(t => `"${t}"`).join(', ')}.`;
261 const newDescription = description + '\n' + voiceLine;
263 // Replace old indented description with new in frontmatter
264 const oldIndented = description.split('\n').map(l => ` ${l}`).join('\n');
265 const newIndented = newDescription.split('\n').map(l => ` ${l}`).join('\n');
266 content = content.replace(oldIndented, newIndented);
268 return content;
269}
271// Export for testing
272export { extractVoiceTriggers, processVoiceTriggers };
274// ─── Catalog Trim (v1.45.0.0 T4) ─────────────────────────────
275//
276// Frontmatter `description:` blocks today pack: a one-line outcome, "Use when
277// asked to..." voice triggers, "Proactively..." routing guidance, and a
278// "(gstack)" tag. This pile is the always-loaded catalog surface — every
279// session pays for the full text. The catalog trim splits the description
280// into a one-line catalog entry (lead sentence + "(gstack)") that stays in
281// the frontmatter and drops the routing/voice prose. The body is not a home
282// for it: Claude Code loads a skill body only after the skill is invoked, so
283// routing text there can no longer route anything. Voice triggers live on in
284// the root router's routing block ({{VOICE_TRIGGER_ROUTING}}).
285//
286// Opt-out: `--catalog-mode=full` keeps v1.44 behavior (no trim, full
287// description in frontmatter). Use when debugging routing regressions or
288// when shipping skills to hosts that depend on the legacy fat catalog.
290export interface CatalogParts {
291 lead: string; // First sentence — kept in catalog
292 routingProse: string; // "Use when asked to...", "Proactively..." paragraphs
293 voiceLine: string | null; // "Voice triggers (speech-to-text aliases): ..." line if present
294 hasGstackTag: boolean;
295}
297export function splitCatalogDescription(description: string): CatalogParts {
298 // Voice triggers line (folded in by processVoiceTriggers earlier)
299 const voiceMatch = description.match(/Voice triggers \(speech-to-text aliases\):[^\n]+/);
300 const voiceLine = voiceMatch ? voiceMatch[0] : null;
301 let working = voiceLine ? description.replace(voiceLine, '').trim() : description.trim();
303 const hasGstackTag = /\(gstack\)/.test(working);
304 if (hasGstackTag) working = working.replace(/\(gstack\)/, '').trim();
306 // Lead = first sentence, ending at the first `.`/`!`/`?` that is followed by
307 // whitespace or end-of-text. Terminator chars NOT followed by whitespace/end
308 // (embedded periods in "TODOS.md", URLs, "v1.45.0.0") are consumed by the
309 // second alternative `[.!?](?!\s|$)` and do NOT end the sentence. The two
310 // alternatives are disjoint character classes, so there is no ambiguity and
311 // no catastrophic-backtracking risk. If no terminator-followed-by-boundary
312 // exists at all, we fall back to a 20-word cut below.
313 // First normalize to single-line for sentence detection, then back out.
314 const collapsed = working.replace(/\s+/g, ' ').trim();
315 const sentenceMatch = collapsed.match(/^((?:[^.!?]|[.!?](?!\s|$))*[.!?])(?:\s|$)/);
316 // sentenceLead is the FULL first sentence (no truncation). We compute routing
317 // from this position, then optionally truncate the displayed lead afterwards.
318 // Truncating first then computing routing was the v1.45.0.0 bug — when the
319 // first sentence exceeded 200 chars, the routing extraction would lose the
320 // entire tail of the description (design-consultation's "Use when..."
321 // routing prose silently dropped).
322 const sentenceLead = sentenceMatch ? sentenceMatch[1].trim() : collapsed.split(/\s/).slice(0, 20).join(' ');
324 // Routing prose: everything AFTER the first sentence boundary in the collapsed view.
325 const leadInCollapsed = collapsed.indexOf(sentenceLead);
326 const routingCollapsed = leadInCollapsed >= 0
327 ? collapsed.slice(leadInCollapsed + sentenceLead.length).trim()
328 : '';
330 // Now produce the displayed lead — truncated if too long. The original
331 // sentenceLead is preserved for routing extraction below.
332 let lead = sentenceLead;
333 if (lead.length > 200) {
334 const trunc = lead.slice(0, 197);
335 const lastSpace = trunc.lastIndexOf(' ');
336 lead = (lastSpace > 60 ? trunc.slice(0, lastSpace) : trunc) + '...';
337 }
338 // Restore line breaks for routing prose by mapping back to original layout.
339 // Use original whitespace structure where possible; fall back to collapsed.
340 // Anchor recovery on sentenceLead (the untruncated first sentence) — not
341 // `lead` (which may have a "..." suffix and won't substring-match `working`).
342 let routingProse = routingCollapsed;
343 const collapsedLeadIdx = working.replace(/\s+/g, ' ').indexOf(sentenceLead);
344 if (collapsedLeadIdx >= 0) {
345 let consumed = 0;
346 let cut = 0;
347 for (let i = 0; i < working.length && consumed < collapsedLeadIdx + sentenceLead.length; i++) {
348 if (/\s/.test(working[i])) {
349 if (i === 0 || /\s/.test(working[i - 1])) continue;
350 consumed += 1;
351 } else {
352 consumed += 1;
353 }
354 cut = i + 1;
355 }
356 const tail = working.slice(cut).trim();
357 if (tail.length > 0) routingProse = tail;
358 }
360 return { lead, routingProse, voiceLine, hasGstackTag };
361}
363/** Build the catalog-trimmed `description:` block. */
364export function buildTrimmedDescription(parts: CatalogParts): string {
365 const lead = parts.lead.trim();
366 const suffix = parts.hasGstackTag ? ' (gstack)' : '';
367 return `${lead}${suffix}`;
368}
370/**
371 * Render a string as a YAML inline scalar value (the text after `key: `),
372 * quoting only when a plain scalar would be invalid or ambiguous.
373 *
374 * The bug this guards (#1778): a description like "Ship workflow: detect..."
375 * emitted as a plain scalar has an interior ": " that a strict YAML parser
376 * (Codex/OpenAI skill loading) reads as a nested mapping and rejects with
377 * "mapping values are not allowed in this context". When quoting is needed we
378 * fall back to JSON.stringify, which produces a double-quoted scalar that YAML
379 * accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that
380 * are already valid plain scalars pass through unchanged to keep regen diffs small.
381 */
382export function toYamlInlineScalar(s: string): string {
383 const needsQuote =
384 s.length === 0 ||
385 s !== s.trim() || // leading/trailing whitespace
386 /:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity
387 /\s#/.test(s) || // " #" → inline comment
388 /\.\.\./.test(s) || // "..." → document-end marker; strict parsers reject mid-scalar (catalog-trim truncation appends it)
389 /^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char
390 return needsQuote ? JSON.stringify(s) : s;
391}
393/**
394 * Apply catalog trim to a SKILL.md body:
395 * - shorten frontmatter `description:` to lead + (gstack)
396 *
397 * Returns the rewritten content plus the extracted parts.
398 */
399export function applyCatalogTrim(content: string, skillName: string): { content: string; parts: CatalogParts } | null {
400 // Locate description block in frontmatter
401 if (!content.startsWith('---\n')) return null;
402 const fmEnd = content.indexOf('\n---', 4);
403 if (fmEnd === -1) return null;
404 const frontmatter = content.slice(4, fmEnd);
406 // Match `description: |` block + indented body lines
407 const descMatch = frontmatter.match(/^description:\s*\|?\s*\n((?:\s{2,}.*(?:\n|$))+)/m)
408 || frontmatter.match(/^description:\s+(.+)$/m);
409 if (!descMatch) return null;
411 // Extract full description text
412 let descText: string;
413 if (descMatch[0].startsWith('description: |') || /^description:\s*\|/.test(descMatch[0])) {
414 descText = descMatch[1].split('\n').map(l => l.replace(/^\s{2}/, '')).join('\n').trim();
415 } else {
416 descText = descMatch[1].trim();
417 }
419 // Skip skills with very short descriptions (already trimmed or no routing prose).
420 // Below ~120 chars, splitting adds no value.
421 if (descText.length < 120) return null;
423 const parts = splitCatalogDescription(descText);
424 // If lead + (gstack) is already most of the text, no trim needed.
425 const trimmedLen = buildTrimmedDescription(parts).length;
426 if (trimmedLen >= descText.length - 20) return null;
428 // Replace description in frontmatter — keep trailing newline so the next
429 // YAML field doesn't collide on the same line as the description value.
430 // Quote the value when it would be an invalid YAML plain scalar (the common
431 // case: an interior ": " like "Ship workflow: detect..." which a strict YAML
432 // parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar
433 // only quotes when needed, so descriptions without special chars stay plain.
434 const newDesc = buildTrimmedDescription(parts);
435 // Function replacer (not a string) so a `$` in the description — e.g. a future
436 // skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement
437 // pattern and silently corrupt the frontmatter.
438 const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`;
439 const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine);
440 const newContent = '---\n' + newFrontmatter + content.slice(fmEnd);
442 return { content: newContent, parts };
443}
445const OPENAI_SHORT_DESCRIPTION_LIMIT = 120;
447function condenseOpenAIShortDescription(description: string): string {
448 const firstParagraph = description.split(/\n\s*\n/)[0] || description;
449 const collapsed = firstParagraph.replace(/\s+/g, ' ').trim();
450 if (collapsed.length <= OPENAI_SHORT_DESCRIPTION_LIMIT) return collapsed;
452 const truncated = collapsed.slice(0, OPENAI_SHORT_DESCRIPTION_LIMIT - 3);
453 const lastSpace = truncated.lastIndexOf(' ');
454 const safe = lastSpace > 40 ? truncated.slice(0, lastSpace) : truncated;
455 return `${safe}...`;
456}
458/**
459 * The sentence Codex's skill picker inserts when a user chooses the skill.
460 * A template sets it with a `codex_default_prompt:` frontmatter key (one line,
461 * optionally quoted; `$<display name>` is Codex's own skill reference). Without
462 * the key the prompt is the generic fallback, so every skill keeps a valid entry.
463 */
464export function extractCodexDefaultPrompt(tmplContent: string): string | undefined {
465 const fmEnd = tmplContent.startsWith('---\n') ? tmplContent.indexOf('\n---', 4) : -1;
466 if (fmEnd === -1) return undefined;
467 const match = tmplContent.slice(4, fmEnd).match(/^codex_default_prompt:[ \t]*(.+?)[ \t]*$/m);
468 if (!match) return undefined;
469 const raw = match[1];
470 const quoted = raw.match(/^"(.*)"$/) || raw.match(/^'(.*)'$/);
471 const value = quoted ? quoted[1] : raw;
472 return value.length > 0 ? value : undefined;
473}
475export function generateOpenAIYaml(displayName: string, shortDescription: string, defaultPrompt?: string): string {
476 return `interface:
477 display_name: ${JSON.stringify(displayName)}
478 short_description: ${JSON.stringify(shortDescription)}
479 default_prompt: ${JSON.stringify(defaultPrompt ?? `Use ${displayName} for this task.`)}
480policy:
481 allow_implicit_invocation: true
482`;
483}
485/**
486 * Transform frontmatter for external hosts.
487 * Claude: strips `sensitive:` field (only Factory uses it).
488 * Codex: keeps name + description only, enforces 1024-char limit.
489 * Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
490 */
491function transformFrontmatter(content: string, host: Host): string {
492 const hostConfig = getHostConfig(host);
493 const fm = hostConfig.frontmatter;
495 if (fm.mode === 'denylist') {
496 // Denylist mode: strip listed fields, keep everything else
497 for (const field of fm.stripFields || []) {
498 if (field === 'voice-triggers') {
499 content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
500 } else {
501 content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), '');
502 }
503 }
504 return content;
505 }
507 // Allowlist mode: reconstruct frontmatter with only allowed fields
508 const fmStart = content.indexOf('---\n');
509 if (fmStart !== 0) return content;
510 const fmEnd = content.indexOf('\n---', fmStart + 4);
511 if (fmEnd === -1) return content;
512 const frontmatter = content.slice(fmStart + 4, fmEnd);
513 const body = content.slice(fmEnd + 4);
514 const { name, description: fullDescription } = extractNameAndDescription(content);
515 const description = fm.descriptionLead ? withTriggerLead(fullDescription, frontmatterList(content, 'triggers'), fm.descriptionLead) : fullDescription;
517 // Description limit enforcement
518 if (fm.descriptionLimit) {
519 const behavior = fm.descriptionLimitBehavior || 'error';
520 if (description.length > fm.descriptionLimit) {
521 if (behavior === 'error') {
522 throw new Error(
523 `${hostConfig.displayName} description for "${name}" is ${description.length} chars (max ${fm.descriptionLimit}). ` +
524 `Compress the description in the .tmpl file.`
525 );
526 } else if (behavior === 'warn') {
527 console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`);
528 }
529 // 'truncate' — silently proceed
530 }
531 }
533 // Build frontmatter with allowed fields
534 const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
535 const fmName = fm.nameMatchesDirectory && name !== 'gstack' && !name.startsWith('gstack-') ? `gstack-${name}` : name;
536 let newFm = `---\nname: ${fmName}\ndescription: |\n${indentedDesc}\n`;
538 // Add extra fields (host-wide)
539 if (fm.extraFields) {
540 for (const [key, value] of Object.entries(fm.extraFields)) {
541 if (key !== 'name' && key !== 'description') {
542 newFm += `${key}: ${value}\n`;
543 }
544 }
545 }
547 // Add conditional fields
548 if (fm.conditionalFields) {
549 for (const rule of fm.conditionalFields) {
550 const match = Object.entries(rule.if).every(([k, v]) =>
551 new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter)
552 );
553 if (match) {
554 for (const [key, value] of Object.entries(rule.add)) {
555 newFm += `${key}: ${value}\n`;
556 }
557 }
558 }
559 }
561 // Preserve additional keepFields beyond name and description
562 if (fm.keepFields) {
563 for (const field of fm.keepFields) {
564 if (field === 'name' || field === 'description') continue;
565 // Match YAML field with possible multi-line/array value (indented lines after colon)
566 const fieldMatch = frontmatter.match(new RegExp(`^${field}:(.*(?:\\n(?:[ \\t]+.+))*)`, 'm'));
567 if (fieldMatch) {
568 newFm += `${field}:${fieldMatch[1]}\n`;
569 }
570 }
571 }
573 // Rename fields (copy values from template frontmatter with new keys)
574 if (fm.renameFields) {
575 for (const [oldName, newName] of Object.entries(fm.renameFields)) {
576 const fieldMatch = frontmatter.match(new RegExp(`^${oldName}:(.+(?:\\n(?:\\s+.+)*)?)`, 'm'));
577 if (fieldMatch) {
578 newFm += `${newName}:${fieldMatch[1]}\n`;
579 }
580 }
581 }
583 newFm += '---';
584 return newFm + body;
585}
587/**
588 * Extract hook descriptions from frontmatter for inline safety prose.
589 * Returns a description of what the hooks do, or null if no hooks.
590 */
591function extractHookSafetyProse(tmplContent: string, hostConfig: HostConfig): string | null {
592 if (!tmplContent.match(/^hooks:/m)) return null;
593 if (hostConfig.capabilities.safetyHooks === 'enforced') return null;
595 // Parse the hook matchers to build a human-readable safety description
596 const matchers: string[] = [];
597 const matcherRegex = /matcher:\s*"(\w+)"/g;
598 let m;
599 while ((m = matcherRegex.exec(tmplContent)) !== null) {
600 if (!matchers.includes(m[1])) matchers.push(m[1]);
601 }
603 if (matchers.length === 0) return null;
605 // Build safety prose based on what tools are hooked
606 const toolDescriptions: Record<string, string> = {
607 Bash: 'check bash commands for destructive operations (rm -rf, DROP TABLE, force-push, git reset --hard, etc.) before execution',
608 Edit: 'verify file edits are within the allowed scope boundary before applying',
609 Write: 'verify file writes are within the allowed scope boundary before applying',
610 };
612 const safetyChecks = matchers
613 .map(t => toolDescriptions[t] || `check ${t} operations for safety`)
614 .join(', and ');
616 return `> **Safety Advisory — not enforced on ${hostConfig.displayName}:** advisory, not blocked. ${hostConfig.displayName} runs no gstack safety hooks, so nothing stops a command automatically. On Claude Code this skill's hooks ${safetyChecks}; here, do those checks yourself: always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
617}
619// ─── External Host Config (now derived from hosts/*.ts) ──────
620// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts
622// ─── Template Processing ────────────────────────────────────
624const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
626/**
627 * Apply a host's configured path + tool rewrites. Extracted so both SKILL.md
628 * (via processExternalHost) and section files (via processSectionTemplate) get
629 * identical per-host treatment — a section's cross-references must rewrite the
630 * same way the parent skill's do, or external hosts get wrong paths.
631 */
632function applyHostRewrites(content: string, hostConfig: HostConfig): string {
633 let result = content;
634 for (const rewrite of hostConfig.pathRewrites) {
635 result = result.replaceAll(rewrite.from, rewrite.to);
636 }
637 if (hostConfig.toolRewrites) {
638 for (const [from, to] of Object.entries(hostConfig.toolRewrites)) {
639 result = result.replaceAll(from, to);
640 }
641 }
642 return result;
643}
645/**
646 * Resolve {{PLACEHOLDER}} / {{NAME:arg}} tokens against the RESOLVERS registry,
647 * honoring host suppression and appliesTo gating, then assert nothing is left
648 * unresolved. Extracted so SKILL.md and section templates resolve through the
649 * exact same path — a security/sanitization fix to one can't miss the other.
650 */
651/**
652 * A second {{PREAMBLE}} in one template re-expands the entire ~12K-token
653 * preamble mid-document (#2508/#2362 — a PROSE mention of the macro in
654 * spec/SKILL.md.tmpl expanded it a second time, +43KB per /spec load).
655 * Resolution is context-blind, so any second occurrence — code fence, prose,
656 * anywhere — is a generation error, never intentional. Throw at render time
657 * so the mistake cannot reach a generated SKILL.md again.
658 */
659export function assertSinglePreamble(tmplContent: string, relTmplPath: string): void {
660 const count = (tmplContent.match(/\{\{PREAMBLE\}\}/g) || []).length;
661 if (count > 1) {
662 throw new Error(
663 `${relTmplPath} contains {{PREAMBLE}} ${count} times — a template may reference it `
664 + `at most once (each occurrence expands the full preamble; see #2508/#2362). `
665 + `Refer to "the preamble" in prose instead of the macro.`,
666 );
667 }
668}
670function resolvePlaceholders(
671 tmplContent: string,
672 ctx: TemplateContext,
673 hostConfig: HostConfig,
674 relTmplPath: string,
675 options: RenderOptions,
676): string {
677 assertSinglePreamble(tmplContent, relTmplPath);
678 // effectiveSuppressedResolvers() honors --respect-detection: when gbrain is
679 // detected locally, GBRAIN_* resolvers un-suppress. Shared by SKILL.md and
680 // section generation so both paths get the same gbrain-aware behavior.
681 const suppressed = effectiveSuppressedResolvers(hostConfig, options);
682 const onePass = (input: string): string =>
683 applySectionGuards(input, ctx).replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
684 const parts = fullKey.split(':');
685 const resolverName = parts[0];
686 const args = parts.slice(1);
687 if (suppressed.has(resolverName)) return '';
688 const resolve = RESOLVERS[resolverName];
689 if (!resolve) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
690 return args.length > 0 ? resolve(ctx, args) : resolve(ctx);
691 });
693 // Multi-pass: a resolver may emit content that itself contains {{TOKENS}} — the
694 // {{SECTION:id}} resolver inlines a section template (with its own resolvers)
695 // for non-Claude hosts. .replace() doesn't re-scan inserted text, so loop until
696 // the output stabilizes. Bounded to avoid an infinite loop if a resolver ever
697 // emits its own placeholder; 6 passes is far more nesting than any skill needs.
698 let content = tmplContent;
699 for (let pass = 0; pass < 6; pass++) {
700 const next = onePass(content);
701 if (next === content) break;
702 content = next;
703 }
705 const remaining = content.match(/\{\{(\w+(?::[^}]+)?)\}\}/g);
706 if (remaining) {
707 throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
708 }
709 return content;
710}
712/**
713 * Build the TemplateContext from a template's frontmatter. Shared by SKILL.md
714 * and section generation so sections inherit the SAME context the parent skill
715 * resolves with (skillName, tier, benefitsFrom, interactive) — enforced by
716 * test/template-context-parity.test.ts. skillNameOverride lets section
717 * generation pin the parent skill's name instead of deriving "sections".
718 */
719function buildContext(
720 tmplContent: string,
721 tmplPath: string,
722 host: Host,
723 options: RenderOptions,
724 skillNameOverride?: string,
725): TemplateContext {
726 const { name: extractedName } = extractNameAndDescription(tmplContent);
727 const skillName = skillNameOverride || extractedName || path.basename(path.dirname(tmplPath));
728 const benefitsMatch = tmplContent.match(/^benefits-from:\s*\[([^\]]*)\]/m);
729 const benefitsFrom = benefitsMatch
730 ? benefitsMatch[1].split(',').map(s => s.trim()).filter(Boolean)
731 : undefined;
732 const tierMatch = tmplContent.match(/^preamble-tier:\s*(\d+)$/m);
733 const preambleTier = tierMatch ? parseInt(tierMatch[1], 10) : undefined;
734 const interactiveMatch = tmplContent.match(/^interactive:\s*(true|false)\s*$/m);
735 const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined;
736 return {
737 skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host],
738 preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, installRoot: options.installRoot, planMode: options.planMode,
739 ...(options.modules ? { modules: options.modules } : {}),
740 };
741}
743/**
744 * Process external host output: routing, frontmatter, path rewrites, metadata.
745 * Shared between Codex and Factory (and future external hosts).
746 */
747function processExternalHost(
748 content: string,
749 tmplContent: string,
750 host: Host,
751 skillDir: string,
752 extractedDescription: string,
753 ctx: TemplateContext,
754 options: RenderOptions,
755 frontmatterName?: string,
756): { content: string; outputPath: string; symlinkLoop: boolean; metadata?: { outputPath: string; content: string } } {
757 const hostConfig = getHostConfig(host);
759 const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
760 // --out-dir mirrors the host tree (outputs only; inputs read from ROOT).
761 const outputDir = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills', name);
762 const outputPath = path.join(outputDir, 'SKILL.md');
764 // Guard against symlink loops
765 let symlinkLoop = false;
766 const claudePath = ctx.tmplPath.replace(/\.tmpl$/, '');
767 try {
768 const resolvedClaude = fs.realpathSync(claudePath);
769 const resolvedExternal = path.join(fs.realpathSync(path.dirname(outputPath)), path.basename(outputPath));
770 if (resolvedClaude === resolvedExternal) {
771 symlinkLoop = true;
772 }
773 } catch {
774 // realpathSync fails if file doesn't exist yet — no symlink loop
775 }
777 // Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
778 const safetyProse = extractHookSafetyProse(tmplContent, hostConfig);
780 // Transform frontmatter (host-aware)
781 let result = transformFrontmatter(content, host);
783 // Insert safety advisory at the top of the body (after frontmatter)
784 if (safetyProse) {
785 const bodyStart = result.indexOf('\n---') + 4;
786 result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
787 }
789 // Config-driven path + tool rewrites (shared with processSectionTemplate so
790 // section cross-references get the same per-host treatment as SKILL.md).
791 result = applyHostRewrites(result, hostConfig);
793 // Config-driven: generate metadata (e.g., openai.yaml for Codex). The
794 // default_prompt comes from the template's codex_default_prompt: key when set.
795 const metadata = hostConfig.generation.generateMetadata && !symlinkLoop ? {
796 outputPath: path.join(outputDir, 'agents', 'openai.yaml'),
797 content: generateOpenAIYaml(name, condenseOpenAIShortDescription(extractedDescription), extractCodexDefaultPrompt(tmplContent)),
798 } : undefined;
800 return { content: result, outputPath, symlinkLoop, metadata };
801}
803/** sections/preamble.md: the shared preamble with the same host rewrites as a section file. */
804function renderSharedPreamble(ctx: TemplateContext, hostConfig: HostConfig, options: RenderOptions): string {
805 let content = generateSharedPreambleFile(ctx);
806 content = ctx.host === 'claude' ? rewriteSectionBase(content, options.contentLinkRoot) : applyHostRewrites(content, hostConfig);
807 content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), hostConfig, options.installRoot);
808 return GENERATED_HEADER.replace('{{SOURCE}}', 'scripts/resolvers/preamble-hoist.ts') + content;
809}
811function processTemplate(tmplPath: string, host: Host, options: RenderOptions): { outputPath: string; content: string; symlinkLoop?: boolean; metadata?: { outputPath: string; content: string }; preamble?: { outputPath: string; content: string } } {
812 // Normalize to LF at the entry point. Templates may have CRLF on disk when
813 // checked out on Windows with core.autocrlf=true. Downstream regexes
814 // (processVoiceTriggers, transformFrontmatter) hardcode \n, so without
815 // normalization they silently no-op on CRLF — producing different output
816 // than CI (Linux, LF) and breaking the Skill Docs Freshness check.
817 // (catalogParts left the return type with the proactive-suggestions
818 // retirement — merge of the two v1.64 waves.)
819 const tmplContent = fs.readFileSync(tmplPath, 'utf-8').replace(/\r\n/g, '\n');
820 const relTmplPath = path.relative(ROOT, tmplPath);
821 let outputPath = tmplPath.replace(/\.tmpl$/, '');
823 // Determine skill directory relative to ROOT
824 const skillDir = path.relative(ROOT, path.dirname(tmplPath));
826 // --out-dir: mirror the skill tree into the out-dir instead of writing in
827 // place (external hosts compute their own output paths below).
828 if (host === 'claude') {
829 outputPath = path.join(options.outputRoot, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, ''));
830 }
832 // Extract name/description: name drives external skill naming + setup symlinks
833 // (and TemplateContext.skillName via buildContext); description feeds external
834 // host metadata. When frontmatter name: differs from directory name (e.g.
835 // run-tests/ with name: test), the frontmatter name wins.
836 const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent);
838 const currentHostConfig = getHostConfig(host);
839 const ctx = buildContext(tmplContent, tmplPath, host, options);
840 const skillName = ctx.skillName;
842 // Replace placeholders + assert none remain (shared path with section generation).
843 let content = resolvePlaceholders(tmplContent, ctx, currentHostConfig, relTmplPath, options);
845 // Preprocess voice triggers: fold into description, strip field from frontmatter.
846 // Must run BEFORE transformFrontmatter so all hosts see the updated description,
847 // and BEFORE extractedDescription is used by external host metadata.
848 content = processVoiceTriggers(content);
850 // Re-extract description AFTER voice trigger preprocessing so Codex openai.yaml
851 // metadata gets the updated description with voice triggers included.
852 const postProcessDescription = extractNameAndDescription(content).description;
854 // For Claude: strip sensitive: field (only Factory uses it)
855 // For external hosts: route output, transform frontmatter, rewrite paths
856 let symlinkLoop = false;
857 let metadata: { outputPath: string; content: string } | undefined;
858 if (host === 'claude') {
859 content = transformFrontmatter(content, host);
860 } else {
861 const result = processExternalHost(content, tmplContent, host, skillDir, postProcessDescription, ctx, options, extractedName || undefined);
862 content = result.content;
863 outputPath = result.outputPath;
864 symlinkLoop = result.symlinkLoop;
865 metadata = result.metadata;
866 }
868 // Prepend generated header (after frontmatter)
869 const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath)) + (options.modules ? `<!-- loadout: ${options.modules.name} -->\n` : '');
870 const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
871 if (fmEnd !== -1) {
872 const insertAt = content.indexOf('\n', fmEnd) + 1;
873 content = content.slice(0, insertAt) + header + content.slice(insertAt);
874 } else {
875 content = header + content;
876 }
878 // Catalog trim (Claude only — external hosts have their own frontmatter shapes)
879 if (host === 'claude' && options.catalogMode === 'trim') {
880 const trimmed = applyCatalogTrim(content, skillName);
881 if (trimmed) content = trimmed.content;
882 }
884 // --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
885 if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot);
886 if (skillDir === '' || skillDir === '.') content = omitDisabledSkills(content, [...options.disabledSkills, ...excludedSkillDirs(options.modules)]);
887 content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), currentHostConfig, options.installRoot);
889 const preamble = hoistsPreamble(ctx) && tmplContent.includes('{{PREAMBLE}}') && !effectiveSuppressedResolvers(currentHostConfig, options).has('PREAMBLE')
890 ? { outputPath: path.join(path.dirname(outputPath), 'sections', SHARED_PREAMBLE_FILE), content: renderSharedPreamble(ctx, currentHostConfig, options) }
891 : undefined;
892 return { outputPath, content, symlinkLoop, metadata, preamble };
893}
895/** autoplan/DRIVER.md (field wave item 13): one host-neutral render of autoplan/DRIVER.md.tmpl through the same resolvers. */
896export const AUTOPLAN_DRIVER_RELPATH = 'autoplan/DRIVER.md';
897export function renderAutoplanDriver(options: RenderOptions): string {
898 const tmplPath = path.join(ROOT, `${AUTOPLAN_DRIVER_RELPATH}.tmpl`);
899 const tmpl = fs.readFileSync(tmplPath, 'utf-8');
900 const ctx = buildContext(tmpl, tmplPath, 'claude', options, 'autoplan');
901 return GENERATED_HEADER.replace('{{SOURCE}}', `${AUTOPLAN_DRIVER_RELPATH}.tmpl`) + resolvePlaceholders(tmpl, ctx, getHostConfig('claude'), `${AUTOPLAN_DRIVER_RELPATH}.tmpl`, options);
902}
904/** ship/DRIVER.md (GSTA-167 PR 5): the compact /ship entry for a parent agent, rendered the same way. */
905export const SHIP_DRIVER_RELPATH = 'ship/DRIVER.md';
906export function renderShipDriver(options: RenderOptions): string {
907 const tmplPath = path.join(ROOT, `${SHIP_DRIVER_RELPATH}.tmpl`);
908 const tmpl = fs.readFileSync(tmplPath, 'utf-8');
909 const ctx = buildContext(tmpl, tmplPath, 'claude', options, 'ship');
910 return GENERATED_HEADER.replace('{{SOURCE}}', `${SHIP_DRIVER_RELPATH}.tmpl`) + resolvePlaceholders(tmpl, ctx, getHostConfig('claude'), `${SHIP_DRIVER_RELPATH}.tmpl`, options);
911}
913/**
914 * Generate one on-demand section file (`<skill>/sections/<name>.md.tmpl` →
915 * `<name>.md`). Sections are BODY FRAGMENTS — no frontmatter, no catalog trim,
916 * no voice triggers. They resolve placeholders through the SAME path as
917 * SKILL.md (resolvePlaceholders) using the PARENT skill's TemplateContext
918 * (so appliesTo gating + tier behave identically — a section's {{PREAMBLE}}-
919 * style resolver renders the same content it would in the parent, not empty).
920 *
921 * Output routing mirrors SKILL.md: Claude writes in-tree at
922 * `<skill>/sections/<name>.md`; external hosts write to
923 * `<hostSubdir>/skills/<externalName>/sections/<name>.md`. External hosts get
924 * applyHostRewrites so cross-references resolve per host.
925 */
926function processSectionTemplate(
927 sectionTmplPath: string,
928 skillDir: string,
929 host: Host,
930 options: RenderOptions,
931): { outputPath: string; content: string } {
932 const tmplContent = fs.readFileSync(sectionTmplPath, 'utf-8');
933 const relTmplPath = path.relative(ROOT, sectionTmplPath);
934 const hostConfig = getHostConfig(host);
936 // Read the owning SKILL.md.tmpl so the section inherits the parent's name +
937 // tier + benefits-from (TemplateContext parity). Fall back to the dir name.
938 const parentTmplPath = path.join(ROOT, skillDir, 'SKILL.md.tmpl');
939 const parentContent = fs.existsSync(parentTmplPath) ? fs.readFileSync(parentTmplPath, 'utf-8') : '';
940 const parentName = (parentContent && extractNameAndDescription(parentContent).name) || skillDir;
941 const ctx = buildContext(parentContent || tmplContent, parentTmplPath, host, options, parentName);
943 // Resolve placeholders against the section body (shared guard catches stragglers).
944 let content = resolvePlaceholders(tmplContent, ctx, hostConfig, relTmplPath, options);
946 // External hosts: rewrite cross-reference paths/tools (no frontmatter to transform).
947 if (host !== 'claude') {
948 content = applyHostRewrites(content, hostConfig);
949 } else {
950 // --out-dir: a section may cross-reference another section by absolute path;
951 // repoint those to the out-dir too (no-op when --out-dir is unset).
952 content = rewriteSectionBase(content, options.contentLinkRoot);
953 }
954 content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), hostConfig, options.installRoot);
956 // Plain generated header (no frontmatter to insert after).
957 content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content;
959 const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, '');
960 let outputPath: string;
961 if (host === 'claude') {
962 outputPath = path.join(options.outputRoot, skillDir, 'sections', fileName);
963 } else {
964 const externalName = externalSkillName(skillDir, parentName);
965 outputPath = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName);
966 }
967 return { outputPath, content };
968}
970// ─── Main ───────────────────────────────────────────────────
972/** Render each artifact once. Artifact writes go through emit(); stale external
973 * caches are pruned only after a successful host render. Dry runs use the same
974 * inventory as normal generation, including metadata and
975 * shared outputs. Options are per invocation so imports/concurrent runs cannot
976 * inherit another caller's model, detection, or output paths.
977 *
978 * templates + host settings -> render -> emit -> dry-run: compare only
979 * | -> normal: mkdir + write
980 * shared index/digest ---------------+ -> artifact inventory + diagnostics
981 * successful external host -----------------> normal only: prune retired caches
982 */
983export async function runGeneration(settings: GenerationOptions = {}): Promise<GenerationResult> {
984 const options: RenderOptions = {
985 outputRoot: path.resolve(settings.outputRoot ?? ROOT),
986 contentLinkRoot: settings.contentLinkRoot ?? null,
987 installRoot: settings.installRoot ?? null,
988 disabledSkills: settings.disabledSkills ?? [],
989 model: settings.model ?? null,
990 catalogMode: settings.catalogMode ?? 'trim',
991 explainLevel: settings.explainLevel ?? 'default',
992 gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false),
993 planMode: settings.planMode ?? true,
994 modules: settings.modules ?? null,
995 };
996 const hosts = settings.host === 'all' ? ALL_HOST_NAMES as Host[] : [settings.host ?? 'claude'];
997 const log = settings.log ?? (() => {});
998 const artifacts: GeneratedArtifact[] = [];
999 const diagnostics: GenerationDiagnostic[] = [];
1000 const templates = discoverTemplates(ROOT);
1001 const sections = discoverSectionTemplates(ROOT);
1002 const rel = (outputPath: string) => path.relative(options.outputRoot, outputPath).split(path.sep).join('/');
1004 function emit(outputPath: string, content: string, kind: GeneratedArtifact['kind'], host?: Host): void {
1005 const relativePath = rel(outputPath);
1006 artifacts.push({ relativePath, kind, ...(host ? { host } : {}) });
1007 try {
1008 if (settings.dryRun) {
1009 let existing: string | undefined;
1010 try {
1011 existing = fs.readFileSync(outputPath, 'utf-8');
1012 } catch (error) {
1013 if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1014 // Windows reports ENOENT for a child of a regular file. Distinguish
1015 // that filesystem error from a missing artifact without writing.
1016 let parent = path.dirname(outputPath);
1017 while (true) {
1018 try {
1019 if (!fs.statSync(parent).isDirectory()) {
1020 throw Object.assign(new Error(`ENOTDIR: output ancestor is not a directory: ${parent}`, { cause: error }), { code: 'ENOTDIR' });
1021 }
1022 break;
1023 } catch (ancestorError) {
1024 if ((ancestorError as NodeJS.ErrnoException).code !== 'ENOENT') throw ancestorError;
1025 const next = path.dirname(parent);
1026 if (next === parent) throw error;
1027 parent = next;
1028 }
1029 }
1030 }
1031 if (existing !== content) {
1032 diagnostics.push({ kind: 'stale', relativePath, host, message: `STALE: ${relativePath}` });
1033 log(`STALE: ${relativePath}`);
1034 } else {
1035 log(`FRESH: ${relativePath}`);
1036 }
1037 } else {
1038 mkdirpSync(path.dirname(outputPath));
1039 fs.writeFileSync(outputPath, content);
1040 log(`GENERATED: ${relativePath}`);
1041 }
1042 } catch (error) {
1043 throw Object.assign(new Error(`${relativePath}: ${(error as Error).message}`), { relativePath });
1044 }
1045 }
1047 function failed(error: unknown, host?: Host): void {
1048 const message = error instanceof Error ? error.message : String(error);
1049 diagnostics.push({ kind: 'error', host, message, relativePath: (error as { relativePath?: string })?.relativePath });
1050 }
1052 for (const host of hosts) {
1053 try {
1054 const hostConfig = getHostConfig(host);
1055 const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
1056 const renderedNames = new Set<string>();
1057 for (const template of templates) {
1058 const skillDir = path.dirname(template.tmpl);
1059 if (!includesSkill(hostConfig, skillDir)) continue;
1060 if (options.modules?.excluded.has(`skill:${skillDir === '.' ? 'gstack' : skillDir}`)) continue;
1061 const result = processTemplate(path.join(ROOT, template.tmpl), host, options);
1062 const relativePath = rel(result.outputPath);
1063 if (host !== 'claude') renderedNames.add(path.basename(path.dirname(result.outputPath)));
1064 if (result.symlinkLoop) {
1065 diagnostics.push({ kind: 'skipped', relativePath, host, message: `SKIPPED (symlink loop): ${relativePath}` });
1066 log(`SKIPPED (symlink loop): ${relativePath}`);
1067 continue;
1068 }
1069 emit(result.outputPath, result.content, 'skill', host);
1070 if (result.metadata) emit(result.metadata.outputPath, result.metadata.content, 'metadata', host);
1071 if (result.preamble) emit(result.preamble.outputPath, result.preamble.content, 'preamble', host);
1072 if (skillDir === 'qa') {
1073 const report = fs.readFileSync(path.join(ROOT, 'qa', 'templates', 'functional-report-template.md'), 'utf-8');
1074 emit(path.join(path.dirname(result.outputPath), 'templates', 'functional-report-template.md'),
1075 (host === 'claude' ? '' : GENERATED_HEADER.replace('{{SOURCE}}', 'qa/templates/functional-report-template.md')) + report, 'asset', host);
1076 }
1077 tokenBudget.push({ skill: relativePath, lines: result.content.split('\n').length, tokens: Math.round(result.content.length / 4) });
1078 const bytes = Buffer.byteLength(result.content, 'utf8');
1079 if (bytes > SKILL_BYTE_CEILING) {
1080 // Setup renders per install (--install-root): an oversized skill must never leave a user with none.
1081 const message = `${host}/${path.basename(path.dirname(result.outputPath))}/SKILL.md is ${bytes} bytes, over the ${SKILL_BYTE_CEILING.toLocaleString('en-US')}-byte limit. Fix: carve sections with usesLazySections() for this skill.`;
1082 diagnostics.push({ kind: options.installRoot ? 'warning' : 'error', host, relativePath, message });
1083 }
1084 }
1086 for (const section of sections) {
1087 if (!includesSkill(hostConfig, section.skillDir) || !usesLazySections(host, section.skillDir)) continue;
1088 if (excludesSection(options.modules, section.skillDir, section.tmpl)) continue;
1089 const result = processSectionTemplate(path.join(ROOT, section.tmpl), section.skillDir, host, options);
1090 emit(result.outputPath, result.content, 'section', host);
1091 tokenBudget.push({ skill: rel(result.outputPath), lines: result.content.split('\n').length, tokens: Math.round(result.content.length / 4) });
1092 }
1094 // Claude owns these catalog-derived runtime assets. Use the same host
1095 // inclusion rule and compare-or-write path as every other artifact.
1096 if (host === 'claude' && includesSkill(hostConfig, 'review')) {
1097 emit(path.join(options.outputRoot, 'review', 'design-checklist.md'),
1098 generateDesignChecklistMd(), 'asset', host);
1099 emit(path.join(options.outputRoot, DOM_DUMP_FILE), DOM_DUMP_SCRIPT + '\n', 'asset', host);
1100 }
1101 if (host === 'claude' && includesSkill(hostConfig, 'autoplan') && templates.some(t => t.tmpl === 'autoplan/SKILL.md.tmpl')) {
1102 emit(path.join(options.outputRoot, AUTOPLAN_DRIVER_RELPATH), renderAutoplanDriver(options), 'asset', host);
1103 }
1104 if (host === 'claude' && includesSkill(hostConfig, 'ship') && templates.some(t => t.tmpl === 'ship/SKILL.md.tmpl')) {
1105 emit(path.join(options.outputRoot, SHIP_DRIVER_RELPATH), renderShipDriver(options), 'asset', host);
1106 }
1108 if (host === 'openclaw') {
1109 for (const variant of ['lite', 'full', 'plan'] as const) {
1110 const fileName = `gstack-${variant}-CLAUDE.md`;
1111 emit(path.join(options.outputRoot, 'openclaw', fileName),
1112 fs.readFileSync(path.join(ROOT, 'openclaw', 'templates', fileName), 'utf-8'), 'openclaw', host);
1113 }
1114 }
1116 // A failed render exits this try before pruning: its inventory is partial.
1117 // Only remove generated directories owned by this host; sidecars and user
1118 // skills survive. Dry runs never create, rewrite, or remove any directory.
1119 if (!settings.dryRun && host !== 'claude') {
1120 const skillsRoot = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills');
1121 let entries: fs.Dirent[] = [];
1122 try {
1123 entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
1124 } catch (error) {
1125 if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1126 }
1127 for (const entry of entries) {
1128 if (entry.isSymbolicLink() || !entry.isDirectory() || !entry.name.startsWith('gstack-') || renderedNames.has(entry.name)) continue;
1129 // Keep the old render usable until setup has migrated installed copies/links.
1130 if (entry.name === 'gstack-claude' && process.env.GSTACK_DEFER_CLAUDE_RENAME_PRUNE === '1') continue;
1131 let generated = false;
1132 try {
1133 generated = fs.readFileSync(path.join(skillsRoot, entry.name, 'SKILL.md'), 'utf-8').includes('<!-- AUTO-GENERATED from');
1134 } catch (error) {
1135 if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1136 }
1137 if (!generated) {
1138 log(` kept ${host} skills/${entry.name}: not a gstack render (no generated banner)`);
1139 continue;
1140 }
1141 fs.rmSync(path.join(skillsRoot, entry.name), { recursive: true, force: true });
1142 log(` pruned stale ${host} render: ${entry.name}`);
1143 if (entry.name === 'gstack-claude') {
1144 log(' /claude is now /claude-code. Run ./setup to migrate installed skill links; generation only updates render files.');
1145 }
1146 }
1147 }
1149 if (!settings.dryRun && tokenBudget.length > 0) {
1150 tokenBudget.sort((a, b) => b.lines - a.lines);
1151 log(`\nToken Budget (${host} host)`);
1152 log('═'.repeat(60));
1153 for (const item of tokenBudget) {
1154 const name = item.skill.replace(/\/SKILL\.md$/, '').replace(`${hostConfig.hostSubdir}/skills/`, '');
1155 log(` ${name.padEnd(30)} ${String(item.lines).padStart(5)} lines ~${String(item.tokens).padStart(6)} tokens`);
1156 }
1157 log('─'.repeat(60));
1158 log(` ${'TOTAL'.padEnd(30)} ${String(tokenBudget.reduce((sum, t) => sum + t.lines, 0)).padStart(5)} lines ~${String(tokenBudget.reduce((sum, t) => sum + t.tokens, 0)).padStart(6)} tokens\n`);
1159 }
1160 } catch (error) {
1161 failed(error, host);
1162 }
1163 }
1165 // Shared artifacts are awaited in both modes. A failure must reach the CLI
1166 // exit code, never disappear in a fire-and-forget auxiliary writer.
1167 try {
1168 const index = await generateLlmsTxt({ excludedSkills: excludedSkillDirs(options.modules) });
1169 emit(path.join(options.outputRoot, 'gstack', 'llms.txt'), index.content, 'index');
1170 for (const warning of index.warnings) diagnostics.push({ kind: 'warning', message: `[gen-llms-txt] ${warning}` });
1171 } catch (error) {
1172 failed(error);
1173 }
1174 try {
1175 const digest = generateAgentsDigest();
1176 emit(path.join(options.outputRoot, DIGEST_RELPATH), digest.content, 'digest');
1177 if (!settings.dryRun) log(`[gen-agents-digest] ${DIGEST_RELPATH}: ${digest.bytes} bytes (budget ${DIGEST_BYTE_BUDGET})`);
1178 } catch (error) {
1179 failed(error);
1180 }
1181 try {
1182 emit(path.join(options.outputRoot, DOCTOR_COMPONENTS_RELPATH), renderDoctorComponentsSh(), 'doctor-components');
1183 } catch (error) {
1184 failed(error);
1185 }
1187 return { exitCode: diagnostics.some(d => d.kind === 'error' || d.kind === 'stale') ? 1 : 0, artifacts, diagnostics };
1188}
1190/** Importing this module never executes generation or reads CLI/user settings.
1191 * Async main is require()-compatible: only top-level await would break callers. */
1192export async function main(args = process.argv.slice(2)): Promise<number> {
1193 try {
1194 const settings = parseGenerationArgs(args);
1195 const result = await runGeneration({ ...settings, log: console.log });
1196 for (const diagnostic of result.diagnostics) {
1197 if (diagnostic.kind === 'error') console.error(`ERROR${diagnostic.host ? ` (${diagnostic.host})` : ''}: ${diagnostic.message}`);
1198 if (diagnostic.kind === 'warning') console.error(diagnostic.message);
1199 }
1200 if (result.diagnostics.some(d => d.kind === 'stale')) {
1201 console.error(`\nGenerated files are stale. Run: bun run gen:skill-docs --host ${settings.host ?? 'claude'}`);
1202 }
1203 if (!settings.dryRun) {
1204 try {
1205 const config = fs.readFileSync(path.join(resolveStateRoot(), 'config.yaml'), 'utf-8');
1206 if (/^skill_prefix:\s*true/m.test(config)) {
1207 console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches (it patches both the install and any active gbrain render).');
1208 }
1209 } catch { /* optional local install note */ }
1210 }
1211 return result.exitCode;
1212 } catch (error) {
1213 if (error instanceof ModuleError) {
1214 console.error(`${error.code}: ${error.message}`);
1215 return 2;
1216 }
1217 console.error(`ERROR: ${(error as Error).message}`);
1218 return 1;
1219 }
1220}
1222if (import.meta.main) {
1223 void main().then(code => { process.exitCode = code; });
1224}