diff --git a/scripts/resolvers/design.ts b/scripts/resolvers/design.ts index 3dc9def81..a5619f0f8 100644 --- a/scripts/resolvers/design.ts +++ b/scripts/resolvers/design.ts @@ -1,6 +1,7 @@ import { outsideVoiceFailurePolicy, outsideVoiceFor, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance } from './outside-voice'; import { type TemplateContext, quoteSafePath, toShellPath } from './types'; import { binaryAssignment } from './runtime-root'; +import { usesLazySections } from './sections'; import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS, CC_BACKGROUND_DEFAULT_SINCE } from './constants'; import { OVERUSED_FONTS_DISPLAY, BANNED_FONTS, FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE, HANDOFF_COMMANDS, selectCatalog, catalogEntries, renderCatalog, detectorSlopEntries, judgmentTellEntries } from '../../lib/design-catalog'; import { SENTINEL, DETECT_EXIT_ECHO, DETECT_LIMITS } from '../../lib/design-detect-contract'; @@ -951,7 +952,8 @@ Exit 2 → one surgical fix pass over the non-advisory rules in the \`${SENTINEL // only), so it carries the brief inline; every other skill keeps its skeleton // small and reads sections/detector-install-offer.md only when the probe printed // the offer (a carved section costs nothing until it is read). - const offer = ctx.skillName === 'design-review' + // External hosts do not carve design-html, so the section file is never installed there: inline it. + const offer = ctx.skillName === 'design-review' || !usesLazySections(ctx.host, ctx.skillName) ? generateDesignDetector(ctx, ['offer']) : `**Install offer (one question, asked once).** If the probe printed \`${SENTINEL.INSTALL_OFFER}\`, Read \`${ctx.paths.skillRoot}/${ctx.skillName}/sections/detector-install-offer.md\` and follow it before any other step; otherwise skip it.`; return `**Design detector (optional, deterministic):** gstack runs impeccable's engine when one is installed under the user's home directory. gstack never runs impeccable's installer, its launcher, or \`npx impeccable\`; the one download it can make is the engine binary itself, only after the user says yes to the offer below, verified against a checksum pinned in gstack. diff --git a/scripts/resolvers/sections.ts b/scripts/resolvers/sections.ts index 4db6a4cd9..da6e51374 100644 --- a/scripts/resolvers/sections.ts +++ b/scripts/resolvers/sections.ts @@ -39,14 +39,18 @@ export function usesLazySections(host: Host, skill: string): boolean { /** * External hosts: a template's literal `~/.claude/skills/gstack//sections/` * reference arrives here as `$GSTACK_ROOT//sections/`, which external - * runtime roots do not have. For a carved skill, point it at the installed copy. + * runtime roots do not have. For a carved skill, point it at the installed copy; + * for this skill's own section inlined by {{SECTION}}, point at the inlined text. */ export function rewriteCarvedSectionRefs(content: string, ctx: TemplateContext): string { if (ctx.host === 'claude') return content; - return content.replace(/`\$GSTACK_ROOT\/([a-z0-9-]+)\/sections\/([A-Za-z0-9._-]+)`/g, (match, skill: string, file: string) => - usesLazySections(ctx.host, skill) && fs.existsSync(path.join(ROOT, skill, 'sections', `${file}.tmpl`)) - ? `\`sections/${file}\` relative to the installed \`gstack-${skill}\` SKILL.md directory` - : match); + return content.replace(/`\$GSTACK_ROOT\/([a-z0-9-]+)\/sections\/([A-Za-z0-9._-]+)`/g, (match, skill: string, file: string) => { + if (!fs.existsSync(path.join(ROOT, skill, 'sections', `${file}.tmpl`))) return match; + if (usesLazySections(ctx.host, skill)) return `\`sections/${file}\` relative to the installed \`gstack-${skill}\` SKILL.md directory`; + const entry = loadManifest(skill).sections.find(e => e.file === file); + const inlined = skill === ctx.skillName && entry && fs.readFileSync(path.join(ROOT, skill, 'SKILL.md.tmpl'), 'utf-8').includes(`{{SECTION:${entry.id}}}`); + return inlined ? `the \`${file.replace(/\.md$/, '')}\` section inlined in this SKILL.md` : match; + }); } interface SectionEntry { diff --git a/test/external-host-carve.test.ts b/test/external-host-carve.test.ts index 232d5f2f3..b9c36d9a4 100644 --- a/test/external-host-carve.test.ts +++ b/test/external-host-carve.test.ts @@ -83,6 +83,23 @@ describe('C4: ship and plan-ceo-review are carved on every external host', () => }); } + test('no external render points at $GSTACK_ROOT//sections/ (runtime roots have no section trees)', () => { + const offenders: string[] = []; + for (const config of EXTERNAL) { + for (const file of skillFiles(path.join(renders.all, config.hostSubdir))) { + const hits = fs.readFileSync(file, 'utf8').match(/\$GSTACK_ROOT\/[a-z0-9-]+\/sections\/[\w.-]+/g) ?? []; + offenders.push(...hits.map(hit => `${path.relative(renders.all, file)}: ${hit}`)); + } + } + expect(offenders).toEqual([]); + const codex = ALL_HOST_CONFIGS.find(c => c.name === 'codex')!; + expect(fs.readFileSync(path.join(skillDir(codex, renders.all, 'plan-eng-review'), 'SKILL.md'), 'utf8')) + .toContain('Read the `review-sections` section inlined in this SKILL.md'); + const html = fs.readFileSync(path.join(skillDir(codex, renders.all, 'design-html'), 'SKILL.md'), 'utf8'); + expect(html).not.toContain('detector-install-offer.md'); + expect(html).toContain('the user has never answered this. Ask now'); + }); + test('codex: the autoplan methodology loader accepts the carved plan-ceo-review install', () => { const codex = ALL_HOST_CONFIGS.find(c => c.name === 'codex')!; const restore = path.join(tmp, 'restore.md');