diff --git a/test/helpers/shipped-skill-routing.ts b/test/helpers/shipped-skill-routing.ts new file mode 100644 index 000000000..d87941e15 --- /dev/null +++ b/test/helpers/shipped-skill-routing.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..', '..'); + +export interface ShippedSkillRouting { + /** The whole `## Skill routing` section gstack offers to append to CLAUDE.md. */ + section: string; + /** The heading plus the opening instruction, without the per-skill rules. */ + instruction: string; + /** The `- request → invoke /skill` rule lines. */ + rules: string[]; +} + +/** + * Read the routing section from the routing-injection heredoc in + * bin/gstack-skill-start, so E2E fixtures use the routing text users receive. + */ +export function readShippedSkillRouting(root = ROOT): ShippedSkillRouting { + const source = fs.readFileSync(path.join(root, 'bin', 'gstack-skill-start'), 'utf8'); + const heredoc = source.match(/<<'EOI' \| _emit_block routing-injection\n([\s\S]*?)\nEOI\n/)?.[1]; + if (!heredoc) throw new Error('bin/gstack-skill-start: routing-injection heredoc not found'); + const start = heredoc.search(/^## Skill routing$/m); + const end = heredoc.search(/^If B:/m); + if (start < 0 || end < start) throw new Error('bin/gstack-skill-start: ## Skill routing section not found'); + const section = heredoc.slice(start, end).trim(); + const rules = section.split('\n').filter(line => /^- .+→ invoke \//.test(line)); + const instruction = section.split(/\n\n/).slice(0, 2).join('\n\n'); + if (rules.length === 0 || !/Skill tool/.test(instruction)) { + throw new Error('bin/gstack-skill-start: ## Skill routing section has an unexpected shape'); + } + return { section, instruction, rules }; +} diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 9a88765e0..d7fd40c82 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -685,14 +685,14 @@ export const E2E_TOUCHFILES: Record = { // test/skill-e2e-context-skills.test.ts. These are periodic-tier because // each one spawns claude -p and costs ~$0.20-$0.40. Collectively they // verify the thing the /checkpoint → /context-save rename was for. - 'context-save-routing': [ 'context-save/**', 'scripts/resolvers/preamble.ts', 'test/skill-e2e-context-skills.test.ts'], - 'context-save-then-restore-roundtrip': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-save/**', 'context-restore/**', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/skill-e2e-context-skills.test.ts'], - 'context-restore-fragment-match': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts'], - 'context-restore-empty-state': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts'], - 'context-restore-list-delegates': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts'], - 'context-restore-legacy-compat': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts'], - 'context-save-list-current-branch': [ 'context-save/**', 'test/skill-e2e-context-skills.test.ts'], - 'context-save-list-all-branches': [ 'context-save/**', 'test/skill-e2e-context-skills.test.ts'], + 'context-save-routing': [ 'context-save/**', 'scripts/resolvers/preamble.ts', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], + 'context-save-then-restore-roundtrip': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-save/**', 'context-restore/**', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], + 'context-restore-fragment-match': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], + 'context-restore-empty-state': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], + 'context-restore-list-delegates': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], + 'context-restore-legacy-compat': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], + 'context-save-list-current-branch': [ 'context-save/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], + 'context-save-list-all-branches': [ 'context-save/**', 'test/skill-e2e-context-skills.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts'], // Document-release 'document-release': [ 'document-release/**', 'test/skill-e2e-workflow.test.ts', 'test/fixtures/coverage-audit-fixture.ts', 'test/helpers/coverage-audit.ts', 'test/helpers/office-hours-attempt.ts'], @@ -884,24 +884,24 @@ export const E2E_TOUCHFILES: Record = { 'scrape-match-path': [ 'scrape/**', 'browse/src/browser-skills.ts', 'browse/src/browser-skill-commands.ts', 'browser-skills/hackernews-frontpage/**', - 'test/skill-e2e-skillify.test.ts' + 'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts' ], 'scrape-prototype-path': [ 'scrape/**', 'browse/src/browser-skills.ts', 'browse/src/browser-skill-commands.ts', - 'test/skill-e2e-skillify.test.ts' + 'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts' ], 'skillify-happy-path': [ 'skillify/**', 'scrape/**', 'browse/src/browser-skill-write.ts', 'browse/src/browser-skills.ts', 'browse/src/browser-skill-commands.ts', - 'test/skill-e2e-skillify.test.ts' + 'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts' ], 'skillify-provenance-refusal': [ 'skillify/**', 'browse/src/browser-skill-write.ts', - 'test/skill-e2e-skillify.test.ts' + 'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts' ], 'skillify-approval-reject': [ 'skillify/**', 'scrape/**', 'browse/src/browser-skill-write.ts', - 'test/skill-e2e-skillify.test.ts' + 'test/skill-e2e-skillify.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts' ], // Skill routing — journey-stage tests (depend on ALL skill descriptions) @@ -909,7 +909,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -918,7 +918,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -927,7 +927,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -936,7 +936,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -945,7 +945,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -954,7 +954,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -963,7 +963,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -972,7 +972,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -981,7 +981,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -990,7 +990,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" @@ -1100,7 +1100,7 @@ export const E2E_TOUCHFILES: Record = { "test/fixtures/plan-scope-recovery-av.json", - "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', + "test/fixtures/design-scope-checkpoint-at.json", '*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/skill-routing-e2e.test.ts', 'bin/gstack-skill-start', 'test/helpers/shipped-skill-routing.ts', "scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts" diff --git a/test/skill-fixture.test.ts b/test/skill-fixture.test.ts index 6b1eba432..6a58562ff 100644 --- a/test/skill-fixture.test.ts +++ b/test/skill-fixture.test.ts @@ -28,6 +28,7 @@ import { CODEX_REVIEW_E2E_SECTIONS, } from './helpers/skill-fixture'; import { E2E_TOUCHFILES, GLOBAL_TOUCHFILES, selectTests } from './helpers/touchfiles'; +import { readShippedSkillRouting } from './helpers/shipped-skill-routing'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -449,10 +450,12 @@ test('routing catalog contains installed project names without a request-to-skil writeFileSync: (file: string, content: string) => writes.set(file, content), }; const compiled = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.slice(start, end) + '\ninstallSkills(fixtureRoot);'); - new Function('ROOT', 'fs', 'path', 'extractSkillHead', 'fixtureRoot', compiled)( - ROOT, mockFs, path, (file: string) => installed.get(file), fixtureRoot, + new Function('ROOT', 'fs', 'path', 'extractSkillHead', 'readShippedSkillRouting', 'fixtureRoot', compiled)( + ROOT, mockFs, path, (file: string) => installed.get(file), readShippedSkillRouting, fixtureRoot, ); const instructions = writes.get(path.join(fixtureRoot, 'CLAUDE.md'))!; + expect(instructions).toContain(readShippedSkillRouting().instruction); + expect(instructions).not.toContain('→ invoke'); expect(instructions).toContain('installed gstack skills: gstack, qa, review.'); expect(instructions).toContain("built-in skills are outside this project's workflow"); expect(instructions).toContain('matching the request to the skill descriptions'); @@ -464,3 +467,18 @@ test('routing catalog contains installed project names without a request-to-skil .toBe(installed.get(path.join(ROOT, 'review/SKILL.md'))); expect(writes.size).toBe(4); }); + +test('routing fixtures read the ## Skill routing section gstack ships', () => { + const routing = readShippedSkillRouting(); + expect(routing.instruction).toStartWith('## Skill routing\n\n'); + expect(routing.instruction).toContain('Skill tool'); + expect(routing.rules.some(rule => rule.includes('/context-save'))).toBe(true); + expect(routing.rules.some(rule => rule.includes('/context-restore'))).toBe(true); + expect(routing.section).not.toContain('If B:'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'shipped-routing-drift-')); + try { + fs.mkdirSync(path.join(root, 'bin')); + fs.writeFileSync(path.join(root, 'bin', 'gstack-skill-start'), '#!/bin/bash\necho no routing here\n'); + expect(() => readShippedSkillRouting(root)).toThrow('routing-injection heredoc not found'); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/skill-routing-e2e.test.ts b/test/skill-routing-e2e.test.ts index a9a2d99cb..2bdc68c31 100644 --- a/test/skill-routing-e2e.test.ts +++ b/test/skill-routing-e2e.test.ts @@ -6,6 +6,7 @@ import { EvalCollector } from './helpers/eval-store'; import type { EvalTestEntry } from './helpers/eval-store'; import { selectTests, detectBaseBranch, getChangedFiles, E2E_TOUCHFILES, E2E_TIERS, GLOBAL_TOUCHFILES } from './helpers/touchfiles'; import { extractSkillHead } from './helpers/skill-fixture'; +import { readShippedSkillRouting } from './helpers/shipped-skill-routing'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -94,26 +95,19 @@ function installSkills(tmpDir: string) { // The names-only catalog keeps new CLI built-ins from changing the candidate // set. Descriptions still choose the skill; no request-to-skill answer key. - // Write a CLAUDE.md with a GENERIC invoke-skills nudge — deliberately NO - // per-skill routing table. These journey tests exist to catch skill - // DESCRIPTION regressions (their touchfiles key on */SKILL.md.tmpl), and - // the old fixture shipped an explicit prompt→skill answer key: with the - // lookup table in context, a badly regressed frontmatter description - // still routed correctly and the tests could not fail on the regression - // class they select for (2026-08 audit). The generic nudge keeps Claude's - // reach-for-a-skill posture; the FRONTMATTER carries the routing load. + // The routing instruction is the one gstack ships (bin/gstack-skill-start), + // without its per-skill rule list. These journey tests exist to catch skill + // DESCRIPTION regressions (their touchfiles key on */SKILL.md.tmpl): with a + // lookup table in context, a badly regressed frontmatter description still + // routes correctly and the tests cannot fail on the regression class they + // select for. The FRONTMATTER carries the routing load. fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), `# Project Instructions -## Skill routing +${readShippedSkillRouting().instruction} This project uses the following installed gstack skills: ${installedSkills.join(', ')}. Choose among this project catalog by matching the request to the skill descriptions. The CLI's built-in skills are outside this project's workflow. - -When the user's request matches an available project skill, ALWAYS invoke it using the Skill -tool as your FIRST action. Do NOT answer directly, do NOT use other tools first. -The skill has specialized workflows that produce better results than ad-hoc answers. -Choose the skill by matching the request against each skill's description. `); }