diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 5fea07713..45f617a1b 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -137,6 +137,39 @@ const EXPLAIN_LEVEL: 'default' | 'terse' = (() => { return val; })(); +// ─── Out-dir (dev workspace render isolation) ─────────────── +// --out-dir redirects Claude SKILL.md + section output to a separate +// (untracked) directory instead of writing in place, AND rewrites the literal +// section-base path (`~/.claude/skills/gstack//sections/`) inside the +// generated content to point at the out-dir, so section Reads resolve to the +// rendered copy rather than the global install. Used by bin/dev-setup to render +// the gbrain `:user` variant for a Conductor workspace without dirtying tracked +// source. Default (unset) = in-place, behavior unchanged. Claude host only. +const OUT_DIR_ARG = process.argv.find(a => a.startsWith('--out-dir')); +const OUT_DIR: string | null = (() => { + if (!OUT_DIR_ARG) return null; + const val = OUT_DIR_ARG.includes('=') + ? OUT_DIR_ARG.split('=')[1] + : process.argv[process.argv.indexOf(OUT_DIR_ARG) + 1]; + if (!val) throw new Error('--out-dir requires a directory path'); + return path.resolve(val); +})(); + +/** + * When rendering to an out-dir, repoint the literal section-base path at the + * out-dir so section Reads resolve to the rendered copy, not the global install. + * Surgical: ONLY paths containing `/sections/` are rewritten — bin/, browse/, + * docs/ references keep pointing at `~/.claude/skills/gstack` (the global + * install, which still works). No-op when --out-dir is unset. + */ +function rewriteSectionBase(content: string): string { + if (!OUT_DIR) return content; + return content.replace( + /~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g, + `${OUT_DIR}/$1`, + ); +} + // HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8) // Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS) // live in ./resolvers/constants and are consumed by resolvers directly. @@ -768,6 +801,12 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: // Determine skill directory relative to ROOT const skillDir = path.relative(ROOT, path.dirname(tmplPath)); + // --out-dir (Claude only): mirror the skill tree into the out-dir instead of + // writing in place. External hosts compute their own paths below. + if (OUT_DIR && host === 'claude') { + outputPath = path.join(OUT_DIR, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, '')); + } + // Extract name/description: name drives external skill naming + setup symlinks // (and TemplateContext.skillName via buildContext); description feeds external // host metadata. When frontmatter name: differs from directory name (e.g. @@ -822,6 +861,9 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: } } + // --out-dir: repoint section-base paths to the out-dir (no-op otherwise). + if (host === 'claude') content = rewriteSectionBase(content); + return { outputPath, content, symlinkLoop, catalogParts }; } @@ -860,6 +902,10 @@ function processSectionTemplate( // External hosts: rewrite cross-reference paths/tools (no frontmatter to transform). if (host !== 'claude') { content = applyHostRewrites(content, hostConfig); + } else { + // --out-dir: a section may cross-reference another section by absolute path; + // repoint those to the out-dir too (no-op when --out-dir is unset). + content = rewriteSectionBase(content); } // Plain generated header (no frontmatter to insert after). @@ -868,7 +914,7 @@ function processSectionTemplate( const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, ''); let outputPath: string; if (host === 'claude') { - outputPath = path.join(ROOT, skillDir, 'sections', fileName); + outputPath = path.join(OUT_DIR || ROOT, skillDir, 'sections', fileName); } else { const externalName = externalSkillName(skillDir, parentName); outputPath = path.join(ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName); @@ -933,7 +979,7 @@ for (const currentHost of hostsToRun) { voice_line: catalogParts.voiceLine, }; } - const relOutput = path.relative(ROOT, outputPath); + const relOutput = path.relative(OUT_DIR || ROOT, outputPath); if (symlinkLoop) { console.log(`SKIPPED (symlink loop): ${relOutput}`); @@ -946,6 +992,9 @@ for (const currentHost of hostsToRun) { console.log(`FRESH: ${relOutput}`); } } else { + // In-place writes land in existing dirs; --out-dir needs the mirrored + // skill dir created first. + if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, content); console.log(`GENERATED: ${relOutput}`); } @@ -982,7 +1031,7 @@ for (const currentHost of hostsToRun) { currentHostConfig.generation.skipSkills.includes(sec.skillDir)) continue; const { outputPath, content } = processSectionTemplate(path.join(ROOT, sec.tmpl), sec.skillDir, currentHost); - const relOutput = path.relative(ROOT, outputPath); + const relOutput = path.relative(OUT_DIR || ROOT, outputPath); if (DRY_RUN) { const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : ''; @@ -1079,7 +1128,9 @@ The orchestrator will persist the plan link to its own memory/knowledge store. // No timestamp field — keeps the file content-deterministic across runs so // CI dry-run freshness checks don't flap on regen. If a per-run timestamp // is ever needed for debugging, write it to a separate `.gen-stamp` file. - if (currentHost === 'claude' && CATALOG_MODE === 'trim' && Object.keys(proactiveAggregate).length > 0 && !DRY_RUN) { + // Skip the global proactive-suggestions.json in --out-dir mode: it lives at + // a repo path (scripts/) and the dev workspace render doesn't need it. + if (currentHost === 'claude' && CATALOG_MODE === 'trim' && Object.keys(proactiveAggregate).length > 0 && !DRY_RUN && !OUT_DIR) { const proactivePath = path.join(ROOT, 'scripts', 'proactive-suggestions.json'); // Sort keys alphabetically so the serialized JSON is identical across // machines regardless of filesystem-iteration order. Without this, CI diff --git a/test/gen-skill-docs-out-dir.test.ts b/test/gen-skill-docs-out-dir.test.ts new file mode 100644 index 000000000..fbc1345e4 --- /dev/null +++ b/test/gen-skill-docs-out-dir.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +// Render the gbrain `:user` variant into a temp out-dir, forcing detection ON +// via a crafted GSTACK_HOME so the test is deterministic regardless of whether +// the dev machine actually has gbrain installed. Asserts the B2 contract: +// (a) the worktree SKILL.md is byte-unchanged (source stays canonical), +// (b) the out-dir SKILL.md gained the inline Brain Context Load block, +// (c) its section refs point at the out-dir, not ~/.claude/skills/gstack, +// (d) bin/ refs are left pointing at the global install, +// (e) the out-dir section file gained the Save Results to Brain block. +describe('gen-skill-docs --out-dir (B2 render isolation)', () => { + function hashFile(p: string): string { + return createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + } + + test('renders :user to out-dir, rewrites section paths, leaves worktree canonical', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-')); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-')); + const worktreeSkill = path.join(ROOT, 'ship', 'SKILL.md'); + const beforeHash = hashFile(worktreeSkill); + try { + // Force gbrain detection ON for --respect-detection. + fs.writeFileSync( + path.join(tmpHome, 'gbrain-detection.json'), + JSON.stringify({ gbrain_local_status: 'ok', gbrain_version: '9.9.9' }), + ); + + const res = spawnSync( + 'bun', + ['run', 'scripts/gen-skill-docs.ts', '--respect-detection', '--host', 'claude', '--out-dir', outDir], + { cwd: ROOT, encoding: 'utf-8', timeout: 120_000, env: { ...process.env, GSTACK_HOME: tmpHome } }, + ); + expect(res.status).toBe(0); + + const outSkill = path.join(outDir, 'ship', 'SKILL.md'); + const outSection = path.join(outDir, 'ship', 'sections', 'adversarial.md'); + expect(fs.existsSync(outSkill)).toBe(true); + const skillContent = fs.readFileSync(outSkill, 'utf-8'); + + // (a) worktree byte-unchanged + expect(hashFile(worktreeSkill)).toBe(beforeHash); + + // (b) inline block present in the rendered SKILL.md + expect(skillContent).toContain('Brain Context Load'); + + // (c) section refs repointed to the out-dir; none left pointing at the install + expect(skillContent).toContain(`${outDir}/ship/sections/`); + expect(skillContent).not.toContain('~/.claude/skills/gstack/ship/sections/'); + + // (d) bin refs are NOT rewritten — they still resolve to the global install + expect(skillContent).toContain('~/.claude/skills/gstack/bin/'); + + // (e) the SAVE block landed in the rendered section file + expect(fs.existsSync(outSection)).toBe(true); + expect(fs.readFileSync(outSection, 'utf-8')).toContain('Save Results to Brain'); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); + + test('global extras (proactive-suggestions.json) are NOT written in out-dir mode', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-out-')); + try { + const res = spawnSync( + 'bun', + ['run', 'scripts/gen-skill-docs.ts', '--host', 'claude', '--out-dir', outDir], + { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 }, + ); + expect(res.status).toBe(0); + // proactive-suggestions.json lives at a repo path; out-dir mode must skip it. + expect(fs.existsSync(path.join(outDir, 'scripts', 'proactive-suggestions.json'))).toBe(false); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); +});