diff --git a/scripts/resolvers/sections.ts b/scripts/resolvers/sections.ts index 6ba2b8a4e..2c5e9c6b2 100644 --- a/scripts/resolvers/sections.ts +++ b/scripts/resolvers/sections.ts @@ -54,11 +54,17 @@ function findSection(skill: string, id: string): SectionEntry { return entry; } +/** + * Pointer to a carved section file. Claude keeps its global-root path; QA and + * every external host point relative to the installed skill directory, because + * external runtime roots (`$GSTACK_ROOT`) carry no `/sections/` tree + * while setup links or copies whole skill directories. + */ export function sectionPath(ctx: TemplateContext, skill: string, id: string): string { const entry = findSection(skill, id); - if (skill === 'qa' || skill === 'qa-only') { + if (ctx.host !== 'claude' || skill === 'qa' || skill === 'qa-only') { fs.accessSync(path.join(ROOT, skill, 'sections', `${entry.file}.tmpl`), fs.constants.R_OK); - const installedName = ctx.host === 'claude' ? `\`${skill}\`/\`gstack-${skill}\`` : `\`gstack-${skill}\``; + const installedName = ctx.host === 'claude' ? `\`${skill}\`/\`gstack-${skill}\`` : `\`${skill.startsWith('gstack-') ? skill : `gstack-${skill}`}\``; return `\`sections/${entry.file}\` relative to the installed ${installedName} SKILL.md directory`; } return `\`${ctx.paths.skillRoot}/${skill}/sections/${entry.file}\``; diff --git a/test/carve-external-hosts.test.ts b/test/carve-external-hosts.test.ts new file mode 100644 index 000000000..a21b98df6 --- /dev/null +++ b/test/carve-external-hosts.test.ts @@ -0,0 +1,28 @@ +/** + * C4: carved skills on external hosts point at sections relative to the + * installed skill directory. `$GSTACK_ROOT//sections/...` does not exist + * in external runtime roots (Codex's holds bin, lib, browse, review only), so a + * root-relative pointer would 404 and the model would work from memory. + */ +import { describe, expect, test } from 'bun:test'; +import { ALL_HOST_CONFIGS } from '../hosts'; +import { sectionPath } from '../scripts/resolvers/sections'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; + +const context = (host: string, skillName: string): TemplateContext => ({ host, skillName, tmplPath: '', paths: HOST_PATHS[host] }); + +describe('C4: external section pointers', () => { + for (const config of ALL_HOST_CONFIGS.filter(c => c.name !== 'claude')) { + test(`${config.name}: ship and plan-ceo-review point relative to the installed skill directory`, () => { + expect(sectionPath(context(config.name, 'ship'), 'ship', 'tests')) + .toBe('`sections/tests.md` relative to the installed `gstack-ship` SKILL.md directory'); + expect(sectionPath(context(config.name, 'plan-ceo-review'), 'plan-ceo-review', 'review-sections')) + .toBe('`sections/review-sections.md` relative to the installed `gstack-plan-ceo-review` SKILL.md directory'); + expect(sectionPath(context(config.name, 'ship'), 'ship', 'tests')).not.toContain('$GSTACK_ROOT'); + }); + } + + test('claude keeps its global-root pointer (bytes unchanged)', () => { + expect(sectionPath(context('claude', 'ship'), 'ship', 'tests')).toBe('`~/.claude/skills/gstack/ship/sections/tests.md`'); + }); +});