diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 9f5460a3..73fc6df6 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -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 = { + 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 \`: 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 \`: tell user "Running gstack v{to} (just updated!)" and continue. +If output shows \`UPGRADE_AVAILABLE \`: 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 \`: 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> = { 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 = { + 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 = `\n\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) {