mirror of https://github.com/garrytan/gstack.git
refactor: gen-skill-docs.ts consumes typed host configs
Replace hardcoded EXTERNAL_HOST_CONFIG, transformFrontmatter host branches, path/tool rewrite if-chains, and ALL_HOSTS array with config-driven lookups from hosts/*.ts. - Host detection uses resolveHostArg() (handles aliases like agents/droid) - transformFrontmatter uses config's allowlist/denylist mode, extraFields, conditionalFields, renameFields, and descriptionLimitBehavior - Path rewrites use config's pathRewrites array (replaceAll, order matters) - Tool rewrites use config's toolRewrites object - Skill skipping uses config's generation.skipSkills - ALL_HOSTS derived from ALL_HOST_NAMES - Token budget display regex derived from host configs Golden-file comparison: all 3 hosts produce IDENTICAL output to baselines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a1a70c2c7
commit
9612b1c82e
|
|
@ -25,7 +25,7 @@ const factory: HostConfig = {
|
||||||
|
|
||||||
generation: {
|
generation: {
|
||||||
generateMetadata: false,
|
generateMetadata: false,
|
||||||
skipSkills: [],
|
skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec
|
||||||
},
|
},
|
||||||
|
|
||||||
pathRewrites: [
|
pathRewrites: [
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const kiro: HostConfig = {
|
||||||
|
|
||||||
generation: {
|
generation: {
|
||||||
generateMetadata: false,
|
generateMetadata: false,
|
||||||
skipSkills: ['codex'],
|
skipSkills: ['codex'], // Codex skill is a Claude wrapper around codex exec
|
||||||
},
|
},
|
||||||
|
|
||||||
pathRewrites: [
|
pathRewrites: [
|
||||||
|
|
|
||||||
|
|
@ -19,22 +19,25 @@ import { HOST_PATHS } from './resolvers/types';
|
||||||
import { RESOLVERS } from './resolvers/index';
|
import { RESOLVERS } from './resolvers/index';
|
||||||
import { externalSkillName, extractHookSafetyProse as _extractHookSafetyProse, extractNameAndDescription as _extractNameAndDescription, condenseOpenAIShortDescription as _condenseOpenAIShortDescription, generateOpenAIYaml as _generateOpenAIYaml } from './resolvers/codex-helpers';
|
import { externalSkillName, extractHookSafetyProse as _extractHookSafetyProse, extractNameAndDescription as _extractNameAndDescription, condenseOpenAIShortDescription as _condenseOpenAIShortDescription, generateOpenAIYaml as _generateOpenAIYaml } from './resolvers/codex-helpers';
|
||||||
import { generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './resolvers/review';
|
import { generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './resolvers/review';
|
||||||
|
import { ALL_HOST_CONFIGS, ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
|
||||||
|
import type { HostConfig } from './host-config';
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dir, '..');
|
const ROOT = path.resolve(import.meta.dir, '..');
|
||||||
const DRY_RUN = process.argv.includes('--dry-run');
|
const DRY_RUN = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
// ─── Host Detection ─────────────────────────────────────────
|
// ─── Host Detection (config-driven) ─────────────────────────
|
||||||
|
|
||||||
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
|
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
|
||||||
type HostArg = Host | 'all';
|
type HostArg = Host | 'all';
|
||||||
const HOST_ARG_VAL: HostArg = (() => {
|
const HOST_ARG_VAL: HostArg = (() => {
|
||||||
if (!HOST_ARG) return 'claude';
|
if (!HOST_ARG) return 'claude';
|
||||||
const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
|
const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
|
||||||
if (val === 'codex' || val === 'agents') return 'codex';
|
|
||||||
if (val === 'factory' || val === 'droid') return 'factory';
|
|
||||||
if (val === 'claude') return 'claude';
|
|
||||||
if (val === 'all') return 'all';
|
if (val === 'all') return 'all';
|
||||||
throw new Error(`Unknown host: ${val}. Use claude, codex, factory, droid, agents, or all.`);
|
try {
|
||||||
|
return resolveHostArg(val) as Host;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unknown host: ${val}. Use ${ALL_HOST_NAMES.join(', ')}, or all.`);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
|
// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
|
||||||
|
|
@ -219,44 +222,85 @@ policy:
|
||||||
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
|
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
|
||||||
*/
|
*/
|
||||||
function transformFrontmatter(content: string, host: Host): string {
|
function transformFrontmatter(content: string, host: Host): string {
|
||||||
if (host === 'claude') {
|
const hostConfig = getHostConfig(host);
|
||||||
// Strip fields not used by Claude: sensitive (Factory-only), voice-triggers (folded into description by preprocessing)
|
const fm = hostConfig.frontmatter;
|
||||||
content = content.replace(/^sensitive:\s*true\n/m, '');
|
|
||||||
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
|
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;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allowlist mode: reconstruct frontmatter with only allowed fields
|
||||||
const fmStart = content.indexOf('---\n');
|
const fmStart = content.indexOf('---\n');
|
||||||
if (fmStart !== 0) return content;
|
if (fmStart !== 0) return content;
|
||||||
const fmEnd = content.indexOf('\n---', fmStart + 4);
|
const fmEnd = content.indexOf('\n---', fmStart + 4);
|
||||||
if (fmEnd === -1) return content;
|
if (fmEnd === -1) return content;
|
||||||
const frontmatter = content.slice(fmStart + 4, fmEnd);
|
const frontmatter = content.slice(fmStart + 4, fmEnd);
|
||||||
const body = content.slice(fmEnd + 4); // includes the leading \n after ---
|
const body = content.slice(fmEnd + 4);
|
||||||
const { name, description } = extractNameAndDescription(content);
|
const { name, description } = extractNameAndDescription(content);
|
||||||
|
|
||||||
if (host === 'codex') {
|
// Description limit enforcement
|
||||||
// Codex 1024-char description limit — fail build, don't ship broken skills
|
if (fm.descriptionLimit) {
|
||||||
const MAX_DESC = 1024;
|
const behavior = fm.descriptionLimitBehavior || 'error';
|
||||||
if (description.length > MAX_DESC) {
|
if (description.length > fm.descriptionLimit) {
|
||||||
throw new Error(
|
if (behavior === 'error') {
|
||||||
`Codex description for "${name}" is ${description.length} chars (max ${MAX_DESC}). ` +
|
throw new Error(
|
||||||
`Compress the description in the .tmpl file.`
|
`${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
|
||||||
}
|
}
|
||||||
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
|
||||||
return `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---` + body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (host === 'factory') {
|
// Build frontmatter with allowed fields
|
||||||
const sensitive = /^sensitive:\s*true/m.test(frontmatter);
|
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
||||||
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`;
|
||||||
let fm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\nuser-invocable: true\n`;
|
|
||||||
if (sensitive) fm += `disable-model-invocation: true\n`;
|
// Add extra fields (host-wide)
|
||||||
fm += '---';
|
if (fm.extraFields) {
|
||||||
return fm + body;
|
for (const [key, value] of Object.entries(fm.extraFields)) {
|
||||||
|
if (key !== 'name' && key !== 'description') {
|
||||||
|
newFm += `${key}: ${value}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return content; // unknown host: passthrough
|
// 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -290,18 +334,8 @@ function extractHookSafetyProse(tmplContent: string): string | null {
|
||||||
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.`;
|
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 ────────────────────────────────────
|
// ─── External Host Config (now derived from hosts/*.ts) ──────
|
||||||
|
// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts
|
||||||
interface ExternalHostConfig {
|
|
||||||
hostSubdir: string; // '.agents' | '.factory'
|
|
||||||
generateMetadata: boolean; // true for codex (openai.yaml), false for factory
|
|
||||||
descriptionLimit?: number; // 1024 for codex, undefined for factory
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXTERNAL_HOST_CONFIG: Record<string, ExternalHostConfig> = {
|
|
||||||
codex: { hostSubdir: '.agents', generateMetadata: true, descriptionLimit: 1024 },
|
|
||||||
factory: { hostSubdir: '.factory', generateMetadata: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Template Processing ────────────────────────────────────
|
// ─── Template Processing ────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -320,11 +354,10 @@ function processExternalHost(
|
||||||
ctx: TemplateContext,
|
ctx: TemplateContext,
|
||||||
frontmatterName?: string,
|
frontmatterName?: string,
|
||||||
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
|
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
|
||||||
const config = EXTERNAL_HOST_CONFIG[host];
|
const hostConfig = getHostConfig(host);
|
||||||
if (!config) throw new Error(`No external host config for: ${host}`);
|
|
||||||
|
|
||||||
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
|
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
|
||||||
const outputDir = path.join(ROOT, config.hostSubdir, 'skills', name);
|
const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name);
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
const outputPath = path.join(outputDir, 'SKILL.md');
|
const outputPath = path.join(outputDir, 'SKILL.md');
|
||||||
|
|
||||||
|
|
@ -353,24 +386,20 @@ function processExternalHost(
|
||||||
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
|
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace hardcoded Claude paths with host-appropriate paths
|
// Config-driven path rewrites (order matters, replaceAll)
|
||||||
result = result.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot);
|
for (const rewrite of hostConfig.pathRewrites) {
|
||||||
result = result.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot);
|
result = result.replaceAll(rewrite.from, rewrite.to);
|
||||||
result = result.replace(/\.claude\/skills\/review/g, `${config.hostSubdir}/skills/gstack/review`);
|
|
||||||
result = result.replace(/\.claude\/skills/g, `${config.hostSubdir}/skills`);
|
|
||||||
|
|
||||||
// Factory-only: translate Claude Code tool names to generic phrasing
|
|
||||||
if (host === 'factory') {
|
|
||||||
result = result.replace(/use the Bash tool/g, 'run this command');
|
|
||||||
result = result.replace(/use the Write tool/g, 'create this file');
|
|
||||||
result = result.replace(/use the Read tool/g, 'read the file');
|
|
||||||
result = result.replace(/use the Agent tool/g, 'dispatch a subagent');
|
|
||||||
result = result.replace(/use the Grep tool/g, 'search for');
|
|
||||||
result = result.replace(/use the Glob tool/g, 'find files matching');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codex-only: generate openai.yaml metadata
|
// Config-driven tool rewrites
|
||||||
if (config.generateMetadata && !symlinkLoop) {
|
if (hostConfig.toolRewrites) {
|
||||||
|
for (const [from, to] of Object.entries(hostConfig.toolRewrites)) {
|
||||||
|
result = result.replaceAll(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config-driven: generate metadata (e.g., openai.yaml for Codex)
|
||||||
|
if (hostConfig.generation.generateMetadata && !symlinkLoop) {
|
||||||
const agentsDir = path.join(outputDir, 'agents');
|
const agentsDir = path.join(outputDir, 'agents');
|
||||||
fs.mkdirSync(agentsDir, { recursive: true });
|
fs.mkdirSync(agentsDir, { recursive: true });
|
||||||
const shortDescription = condenseOpenAIShortDescription(extractedDescription);
|
const shortDescription = condenseOpenAIShortDescription(extractedDescription);
|
||||||
|
|
@ -463,7 +492,7 @@ function findTemplates(): string[] {
|
||||||
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
|
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_HOSTS: Host[] = ['claude', 'codex', 'factory'];
|
const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];
|
||||||
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
|
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
|
||||||
const failures: { host: string; error: Error }[] = [];
|
const failures: { host: string; error: Error }[] = [];
|
||||||
|
|
||||||
|
|
@ -475,10 +504,11 @@ for (const currentHost of hostsToRun) {
|
||||||
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
|
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
|
||||||
|
|
||||||
for (const tmplPath of findTemplates()) {
|
for (const tmplPath of findTemplates()) {
|
||||||
// Skip /codex skill for non-Claude hosts (it's a Claude wrapper around codex exec)
|
// Skip skills listed in host config's generation.skipSkills
|
||||||
if (currentHost !== 'claude') {
|
const currentHostConfig = getHostConfig(currentHost);
|
||||||
|
if (currentHostConfig.generation.skipSkills?.length) {
|
||||||
const dir = path.basename(path.dirname(tmplPath));
|
const dir = path.basename(path.dirname(tmplPath));
|
||||||
if (dir === 'codex') continue;
|
if (currentHostConfig.generation.skipSkills.includes(dir)) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
|
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
|
||||||
|
|
@ -521,7 +551,8 @@ for (const currentHost of hostsToRun) {
|
||||||
console.log(`Token Budget (${currentHost} host)`);
|
console.log(`Token Budget (${currentHost} host)`);
|
||||||
console.log('═'.repeat(60));
|
console.log('═'.repeat(60));
|
||||||
for (const t of tokenBudget) {
|
for (const t of tokenBudget) {
|
||||||
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(/^\.(agents|factory)\/skills\//, '');
|
const hostSubdirs = ALL_HOST_CONFIGS.map(c => c.hostSubdir.replace('.', '\\.')).join('|');
|
||||||
|
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(new RegExp(`^\\.(${hostSubdirs})\\/skills\\/`), '');
|
||||||
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
|
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
|
||||||
}
|
}
|
||||||
console.log('─'.repeat(60));
|
console.log('─'.repeat(60));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue