diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 4f41e66ca..d4e368a2c 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -73,7 +73,7 @@ cd ~/.claude/skills/gstack && ./setup --team ``` Skills like /qa, /ship, /review, /investigate, and /browse become available after install. -Use /browse for all web browsing. Use ~/.claude/skills/gstack/... for gstack file paths.' +Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback). Use ~/.claude/skills/gstack/... for gstack file paths.' else SNIPPET='## gstack (REQUIRED — global install) @@ -100,7 +100,7 @@ If GSTACK_MISSING: STOP. Do not proceed. Tell the user: Do not skip skills, ignore gstack errors, or work around missing gstack. Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, -and /browse are available. Use /browse for all web browsing. +and /browse are available. Use /browse for all web browsing (Aside first, the bundled gstack browser as fallback). Use the resolved install path above for gstack file paths (default: ~/.claude/skills/gstack).' fi diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 0be3ca9a1..78ba6f9b2 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -170,6 +170,12 @@ function parsePathFlag(flag: string): string | null { } const OUT_DIR: string | null = parsePathFlag('--out-dir'); +// External-host outputs rendered in THIS run, keyed by host. Used after the +// render to prune `gstack-*` output dirs whose skill no longer exists: the +// generator never deleted, so a retired skill stayed rendered (and linked by +// setup) forever, still reading config keys the DEFAULTS table had dropped. +const RENDERED_EXTERNAL: Map> = new Map(); + // #2692: callers that render into a TMP dir and atomically swap it into place // (bin/gstack-config gbrain-refresh, setup — the #2569 pattern) must pass the // FINAL directory here, or rewriteSectionBase bakes the tmp path @@ -799,6 +805,8 @@ function processExternalHost( const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName); // --out-dir mirrors the host tree (outputs only; inputs read from ROOT). const outputDir = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', name); + if (!RENDERED_EXTERNAL.has(host)) RENDERED_EXTERNAL.set(host, new Set()); + RENDERED_EXTERNAL.get(host)!.add(name); fs.mkdirSync(outputDir, { recursive: true }); const outputPath = path.join(outputDir, 'SKILL.md'); @@ -1166,6 +1174,24 @@ if (!DRY_RUN) { } catch { /* non-fatal */ } } +// Prune stale external-host outputs. A run always renders every skill for the +// chosen host(s) (there is no per-skill filter), so any `gstack-*` directory +// left in /skills/ that this run did not write belongs to a skill that +// no longer exists. Symlinks (the `gstack` sidecar) and non-prefixed entries +// are never touched. +if (!DRY_RUN) { + for (const [host, names] of RENDERED_EXTERNAL) { + const skillsRoot = path.join(OUT_DIR ?? ROOT, getHostConfig(host as Host).hostSubdir, 'skills'); + let entries: fs.Dirent[] = []; + try { entries = fs.readdirSync(skillsRoot, { withFileTypes: true }); } catch { continue; } + for (const e of entries) { + if (e.isSymbolicLink() || !e.isDirectory() || !e.name.startsWith('gstack-') || names.has(e.name)) continue; + fs.rmSync(path.join(skillsRoot, e.name), { recursive: true, force: true }); + console.log(` pruned stale ${host} render: ${e.name}`); + } + } +} + // Regenerate gstack/llms.txt — single-file capability index for AI agents. // Runs after SKILL.md generation so it sees current skill descriptions and // browse command list. Wrapped in an IIFE so the await-import doesn't make diff --git a/setup b/setup index 4f28bd49d..433cfb451 100755 --- a/setup +++ b/setup @@ -134,6 +134,28 @@ _owned_for_windows_refresh() { grep -q '\n'; + +function mk(t: string) { + const src = path.join(t, 'src'); + const gen = path.join(t, 'gen'); + const host = path.join(t, 'host'); + // Source templates: a flat skill and the one prefixed source (gstack-upgrade). + for (const s of ['qa', 'gstack-upgrade']) { + fs.mkdirSync(path.join(src, s), { recursive: true }); + fs.writeFileSync(path.join(src, s, 'SKILL.md.tmpl'), 'x'); + } + // Generated tree: live renders + two retired ones + the gstack sidecar. + for (const g of ['gstack-qa', 'gstack-upgrade', 'gstack-oldskill', 'gstack-gone', 'gstack']) { + fs.mkdirSync(path.join(gen, g), { recursive: true }); + fs.writeFileSync(path.join(gen, g, 'SKILL.md'), `${BANNER}# ${g}\n`); + } + fs.mkdirSync(host, { recursive: true }); + // Host entries: symlink (Unix), bannered real copy (Windows/Kiro), user's own dir. + fs.symlinkSync(path.join(gen, 'gstack-qa') + '/', path.join(host, 'gstack-qa')); + fs.symlinkSync(path.join(gen, 'gstack-oldskill') + '/', path.join(host, 'gstack-oldskill')); + fs.mkdirSync(path.join(host, 'gstack-gone')); + fs.writeFileSync(path.join(host, 'gstack-gone', 'SKILL.md'), `${BANNER}copy\n`); + fs.mkdirSync(path.join(host, 'gstack-mine')); + fs.writeFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), '---\nname: mine\n---\nuser skill\n'); + return { src, gen, host }; +} + +function runPrune(src: string, gen: string, host?: string) { + const script = [ + 'set -e', + extractFn('_owned_for_windows_refresh'), + extractFn('_prune_stale_generated'), + `_prune_stale_generated "${src}" "${gen}" ${host ? `"${host}"` : ''}`, + ].join('\n'); + return spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 30_000 }); +} + +describe('setup: _prune_stale_generated', () => { + test('call sites: every host link + the always-run codex render are pruned', () => { + for (const site of [ + '"$SOURCE_GSTACK_DIR/.agents/skills"', + '"$SOURCE_GSTACK_DIR/.agents/skills" "$CODEX_SKILLS"', + '"$SOURCE_GSTACK_DIR/.factory/skills" "$FACTORY_SKILLS"', + '"$SOURCE_GSTACK_DIR/.opencode/skills" "$OPENCODE_SKILLS"', + '"$SOURCE_GSTACK_DIR/.cursor/skills" "$CURSOR_SKILLS"', + '"$AGENTS_DIR" "$KIRO_SKILLS"', + ]) { + expect(SETUP_SRC).toContain(`_prune_stale_generated "$SOURCE_GSTACK_DIR" ${site}`); + } + }); + + test('retired renders go; live, prefixed-source, and sidecar dirs stay; host entries follow provenance', () => { + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-')); + try { + const { src, gen, host } = mk(t); + const r = runPrune(src, gen, host); + expect(r.status).toBe(0); + expect(r.stdout).toContain('pruned retired skill: gstack-oldskill'); + expect(r.stdout).toContain('pruned retired skill: gstack-gone'); + + expect(fs.readdirSync(gen).sort()).toEqual(['gstack', 'gstack-qa', 'gstack-upgrade']); + // Symlink to a retired render + bannered copy of one: removed. + expect(fs.existsSync(path.join(host, 'gstack-oldskill'))).toBe(false); + expect(fs.lstatSync(path.join(host, 'gstack-oldskill'), { throwIfNoEntry: false })).toBeUndefined(); + expect(fs.existsSync(path.join(host, 'gstack-gone'))).toBe(false); + // Live link and the user's own (unbannered) dir: untouched. + expect(fs.lstatSync(path.join(host, 'gstack-qa')).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(path.join(host, 'gstack-mine', 'SKILL.md'), 'utf-8')).toContain('user skill'); + } finally { + fs.rmSync(t, { recursive: true, force: true }); + } + }); + + test('no host dir → prunes the render tree only; missing render tree → no-op', () => { + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-')); + try { + const { src, gen, host } = mk(t); + expect(runPrune(src, gen).status).toBe(0); + expect(fs.existsSync(path.join(gen, 'gstack-oldskill'))).toBe(false); + expect(fs.lstatSync(path.join(host, 'gstack-oldskill')).isSymbolicLink()).toBe(true); // dangling, but not ours to touch here + + const r = runPrune(src, path.join(t, 'nope'), host); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + } finally { + fs.rmSync(t, { recursive: true, force: true }); + } + }); +});