refactor: host-aware gen-skill-docs + --host codex generation

Refactor gen-skill-docs.ts for multi-agent support:
- Add Host type, HostPaths interface, HOST_PATHS config
- Decompose generatePreamble() into 7 composable sub-functions
- Replace all hardcoded .claude/skills/gstack paths with ctx.paths
- Replace static findTemplates() list with dynamic filesystem scan
- Add --host codex|agents flag (aliases, same output)
- Add processTemplate host routing to .agents/skills/gstack-*/
- Add codexSkillName() with double-prefix prevention
- Add transformFrontmatter() — keeps only name + description for Codex
- Add extractHookSafetyProse() — converts hooks to inline advisory
- Add body text path rewriting for remaining hardcoded paths
- Exclude /codex skill from Codex generation (self-referential)

Claude output is unchanged (verified via --dry-run).
SKILL.md is an open standard: .agents/skills/ works on Codex, Gemini CLI, and Cursor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-19 00:52:33 -07:00
parent d961188276
commit f5733e0d27
+224 -49
View File
@@ -19,9 +19,44 @@ const DRY_RUN = process.argv.includes('--dry-run');
// ─── Template Context ───────────────────────────────────────
type Host = 'claude' | 'codex';
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
const HOST: Host = (() => {
if (!HOST_ARG) return 'claude';
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 === 'claude') return 'claude';
throw new Error(`Unknown host: ${val}. Use claude, codex, or agents.`);
})();
interface HostPaths {
skillRoot: string;
localSkillRoot: string;
binDir: string;
browseDir: string;
}
const HOST_PATHS: Record<Host, HostPaths> = {
claude: {
skillRoot: '~/.claude/skills/gstack',
localSkillRoot: '.claude/skills/gstack',
binDir: '~/.claude/skills/gstack/bin',
browseDir: '~/.claude/skills/gstack/browse/dist',
},
codex: {
skillRoot: '~/.codex/skills/gstack',
localSkillRoot: '.agents/skills/gstack',
binDir: '~/.codex/skills/gstack/bin',
browseDir: '~/.codex/skills/gstack/browse/dist',
},
};
interface TemplateContext {
skillName: string;
tmplPath: string;
host: Host;
paths: HostPaths;
}
// ─── Placeholder Resolvers ──────────────────────────────────
@@ -101,18 +136,18 @@ function generateSnapshotFlags(_ctx: TemplateContext): string {
return lines.join('\n');
}
function generatePreamble(ctx: TemplateContext): string {
function generatePreambleBash(ctx: TemplateContext): string {
return `## Preamble (run first)
\`\`\`bash
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
_UPD=$(${ctx.paths.binDir}/gstack-update-check 2>/dev/null || ${ctx.paths.localSkillRoot}/bin/gstack-update-check 2>/dev/null || true)
[ -n "$_UPD" ] && echo "$_UPD" || true
mkdir -p ~/.gstack/sessions
touch ~/.gstack/sessions/"$PPID"
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
find ~/.gstack/sessions -mmin +120 -type f -delete 2>/dev/null || true
_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || true)
_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true")
_CONTRIB=$(${ctx.paths.binDir}/gstack-config get gstack_contributor 2>/dev/null || true)
_PROACTIVE=$(${ctx.paths.binDir}/gstack-config get proactive 2>/dev/null || echo "true")
_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
echo "BRANCH: $_BRANCH"
echo "PROACTIVE: $_PROACTIVE"
@@ -120,14 +155,18 @@ _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no
echo "LAKE_INTRO: $_LAKE_SEEN"
mkdir -p ~/.gstack/analytics
echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
\`\`\`
\`\`\``;
}
If \`PROACTIVE\` is \`"false"\`, do not proactively suggest gstack skills — only invoke
function generateUpgradeCheck(ctx: TemplateContext): string {
return `If \`PROACTIVE\` is \`"false"\`, do not proactively suggest gstack skills — only invoke
them when the user explicitly asks. The user opted out of proactive suggestions.
If output shows \`UPGRADE_AVAILABLE <old> <new>\`: read \`~/.claude/skills/gstack/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If \`JUST_UPGRADED <from> <to>\`: tell user "Running gstack v{to} (just updated!)" and continue.
If output shows \`UPGRADE_AVAILABLE <old> <new>\`: read \`${ctx.paths.skillRoot}/gstack-upgrade/SKILL.md\` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If \`JUST_UPGRADED <from> <to>\`: tell user "Running gstack v{to} (just updated!)" and continue.`;
}
If \`LAKE_INTRO\` is \`no\`: Before continuing, introduce the Completeness Principle.
function generateLakeIntro(): string {
return `If \`LAKE_INTRO\` is \`no\`: Before continuing, introduce the Completeness Principle.
Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete
thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean"
Then offer to open the essay in their default browser:
@@ -137,9 +176,11 @@ open https://garryslist.org/posts/boil-the-ocean
touch ~/.gstack/.completeness-intro-seen
\`\`\`
Only run \`open\` if the user says yes. Always run \`touch\` to mark as seen. This only happens once.
Only run \`open\` if the user says yes. Always run \`touch\` to mark as seen. This only happens once.`;
}
## AskUserQuestion Format
function generateAskUserFormat(_ctx: TemplateContext): string {
return `## AskUserQuestion Format
**ALWAYS follow this structure for every AskUserQuestion call:**
1. **Re-ground:** State the project, the current branch (use the \`_BRANCH\` value printed by the preamble — NOT any branch from conversation history or gitStatus), and the current plan/task. (1-2 sentences)
@@ -149,9 +190,11 @@ Only run \`open\` if the user says yes. Always run \`touch\` to mark as seen. Th
Assume the user hasn't looked at this window in 20 minutes and doesn't have the code open. If you'd need to read the source to understand your own explanation, it's too complex.
Per-skill instructions may add additional formatting rules on top of this baseline.
Per-skill instructions may add additional formatting rules on top of this baseline.`;
}
## Completeness Principle — Boil the Lake
function generateCompletenessSection(): string {
return `## Completeness Principle — Boil the Lake
AI-assisted coding makes the marginal cost of completeness near-zero. When you present options:
@@ -174,9 +217,11 @@ AI-assisted coding makes the marginal cost of completeness near-zero. When you p
- BAD: "Choose B — it covers 90% of the value with less code." (If A is only 70 lines more, choose A.)
- BAD: "We can skip edge case handling to save time." (Edge case handling costs minutes with CC.)
- BAD: "Let's defer test coverage to a follow-up PR." (Tests are the cheapest lake to boil.)
- BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks human / ~1 hour CC.")
- BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks human / ~1 hour CC.")`;
}
## Contributor Mode
function generateContributorMode(): string {
return `## Contributor Mode
If \`_CONTRIB\` is \`true\`: you are in **contributor mode**. You're a gstack user who also helps make it better.
@@ -211,9 +256,11 @@ Hey gstack team — ran into this while using /{skill-name}:
**Date:** {YYYY-MM-DD} | **Version:** {gstack version} | **Skill:** /{skill}
\`\`\`
Slug: lowercase, hyphens, max 60 chars (e.g. \`browse-js-no-await\`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}"
Slug: lowercase, hyphens, max 60 chars (e.g. \`browse-js-no-await\`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}"`;
}
## Completion Status Protocol
function generateCompletionStatus(): string {
return `## Completion Status Protocol
When completing a skill workflow, report status using one of:
- **DONE** — All steps completed successfully. Evidence provided for each claim.
@@ -239,14 +286,26 @@ RECOMMENDATION: [what the user should do next]
\`\`\``;
}
function generateBrowseSetup(_ctx: TemplateContext): string {
function generatePreamble(ctx: TemplateContext): string {
return [
generatePreambleBash(ctx),
generateUpgradeCheck(ctx),
generateLakeIntro(),
generateAskUserFormat(ctx),
generateCompletenessSection(),
generateContributorMode(),
generateCompletionStatus(),
].join('\n\n');
}
function generateBrowseSetup(ctx: TemplateContext): string {
return `## SETUP (run this check BEFORE any browse command)
\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
[ -z "$B" ] && B=${ctx.paths.browseDir}/browse
if [ -x "$B" ]; then
echo "READY: $B"
else
@@ -1144,19 +1203,127 @@ const RESOLVERS: Record<string, (ctx: TemplateContext) => string> = {
TEST_BOOTSTRAP: generateTestBootstrap,
};
// ─── Codex Helpers ───────────────────────────────────────────
function codexSkillName(skillDir: string): string {
if (skillDir === '.' || skillDir === '') return 'gstack';
// Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade)
if (skillDir.startsWith('gstack-')) return skillDir;
return `gstack-${skillDir}`;
}
/**
* Transform frontmatter for Codex: keep only name + description.
* Strips allowed-tools, hooks, version, and all other fields.
* Handles multiline block scalar descriptions (YAML | syntax).
*/
function transformFrontmatter(content: string, host: Host): string {
if (host === 'claude') return content;
// Find frontmatter boundaries
const fmStart = content.indexOf('---\n');
if (fmStart !== 0) return content; // frontmatter must be at the start
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); // includes the leading \n after ---
// Parse name
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
const name = nameMatch ? nameMatch[1].trim() : '';
// Parse description — handle both simple and block scalar (|) formats
let description = '';
const lines = frontmatter.split('\n');
let inDescription = false;
const descLines: string[] = [];
for (const line of lines) {
if (line.match(/^description:\s*\|?\s*$/)) {
// Block scalar start: "description: |" or "description:"
inDescription = true;
continue;
}
if (line.match(/^description:\s*\S/)) {
// Simple inline: "description: some text"
description = line.replace(/^description:\s*/, '').trim();
break;
}
if (inDescription) {
// Block scalar continuation — indented lines (2 spaces) or blank lines
if (line === '' || line.match(/^\s/)) {
descLines.push(line.replace(/^ /, ''));
} else {
// End of block scalar — hit a non-indented, non-blank line
break;
}
}
}
if (descLines.length > 0) {
description = descLines.join('\n').trim();
}
// Re-emit Codex frontmatter (name + description only)
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
const codexFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---`;
return codexFm + 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<string, string> = {
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.`;
}
// ─── Template Processing ────────────────────────────────────
const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
function processTemplate(tmplPath: string): { outputPath: string; content: string } {
function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: string; content: string } {
const tmplContent = fs.readFileSync(tmplPath, 'utf-8');
const relTmplPath = path.relative(ROOT, tmplPath);
const outputPath = tmplPath.replace(/\.tmpl$/, '');
let outputPath = tmplPath.replace(/\.tmpl$/, '');
// Determine skill directory relative to ROOT
const skillDir = path.relative(ROOT, path.dirname(tmplPath));
// For codex host, route output to .agents/skills/{codexSkillName}/SKILL.md
if (host === 'codex') {
const codexName = codexSkillName(skillDir === '.' ? '' : skillDir);
const outputDir = path.join(ROOT, '.agents', 'skills', codexName);
fs.mkdirSync(outputDir, { recursive: true });
outputPath = path.join(outputDir, 'SKILL.md');
}
// Extract skill name from frontmatter for TemplateContext
const nameMatch = tmplContent.match(/^name:\s*(.+)$/m);
const skillName = nameMatch ? nameMatch[1].trim() : path.basename(path.dirname(tmplPath));
const ctx: TemplateContext = { skillName, tmplPath };
const ctx: TemplateContext = { skillName, tmplPath, host, paths: HOST_PATHS[host] };
// Replace placeholders
let content = tmplContent.replace(/\{\{(\w+)\}\}/g, (match, name) => {
@@ -1171,6 +1338,27 @@ function processTemplate(tmplPath: string): { outputPath: string; content: strin
throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
}
// For codex host: transform frontmatter and replace Claude-specific paths
if (host === 'codex') {
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
const safetyProse = extractHookSafetyProse(tmplContent);
// Transform frontmatter: keep only name + description
content = transformFrontmatter(content, host);
// Insert safety advisory at the top of the body (after frontmatter)
if (safetyProse) {
const bodyStart = content.indexOf('\n---') + 4;
content = content.slice(0, bodyStart) + '\n' + safetyProse + '\n' + content.slice(bodyStart);
}
// Replace remaining hardcoded Claude paths with host-appropriate paths
content = content.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot);
content = content.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot);
content = content.replace(/\.claude\/skills\/review/g, '.agents/skills/gstack-review');
content = content.replace(/\.claude\/skills/g, '.agents/skills');
}
// Prepend generated header (after frontmatter)
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
@@ -1188,32 +1376,13 @@ function processTemplate(tmplPath: string): { outputPath: string; content: strin
function findTemplates(): string[] {
const templates: string[] = [];
const candidates = [
path.join(ROOT, 'SKILL.md.tmpl'),
path.join(ROOT, 'browse', 'SKILL.md.tmpl'),
path.join(ROOT, 'qa', 'SKILL.md.tmpl'),
path.join(ROOT, 'qa-only', 'SKILL.md.tmpl'),
path.join(ROOT, 'setup-browser-cookies', 'SKILL.md.tmpl'),
path.join(ROOT, 'ship', 'SKILL.md.tmpl'),
path.join(ROOT, 'review', 'SKILL.md.tmpl'),
path.join(ROOT, 'plan-ceo-review', 'SKILL.md.tmpl'),
path.join(ROOT, 'plan-eng-review', 'SKILL.md.tmpl'),
path.join(ROOT, 'retro', 'SKILL.md.tmpl'),
path.join(ROOT, 'office-hours', 'SKILL.md.tmpl'),
path.join(ROOT, 'debug', 'SKILL.md.tmpl'),
path.join(ROOT, 'gstack-upgrade', 'SKILL.md.tmpl'),
path.join(ROOT, 'plan-design-review', 'SKILL.md.tmpl'),
path.join(ROOT, 'design-review', 'SKILL.md.tmpl'),
path.join(ROOT, 'design-consultation', 'SKILL.md.tmpl'),
path.join(ROOT, 'document-release', 'SKILL.md.tmpl'),
path.join(ROOT, 'codex', 'SKILL.md.tmpl'),
path.join(ROOT, 'careful', 'SKILL.md.tmpl'),
path.join(ROOT, 'freeze', 'SKILL.md.tmpl'),
path.join(ROOT, 'guard', 'SKILL.md.tmpl'),
path.join(ROOT, 'unfreeze', 'SKILL.md.tmpl'),
];
for (const p of candidates) {
if (fs.existsSync(p)) templates.push(p);
const rootTmpl = path.join(ROOT, 'SKILL.md.tmpl');
if (fs.existsSync(rootTmpl)) templates.push(rootTmpl);
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const tmpl = path.join(ROOT, entry.name, 'SKILL.md.tmpl');
if (fs.existsSync(tmpl)) templates.push(tmpl);
}
return templates;
}
@@ -1221,7 +1390,13 @@ function findTemplates(): string[] {
let hasChanges = false;
for (const tmplPath of findTemplates()) {
const { outputPath, content } = processTemplate(tmplPath);
// Skip /codex skill for codex host (self-referential — it's a Claude wrapper around codex exec)
if (HOST === 'codex') {
const dir = path.basename(path.dirname(tmplPath));
if (dir === 'codex') continue;
}
const { outputPath, content } = processTemplate(tmplPath, HOST);
const relOutput = path.relative(ROOT, outputPath);
if (DRY_RUN) {