diff --git a/SKILL.md.tmpl b/SKILL.md.tmpl index 8065cd9c7..3326854f2 100644 --- a/SKILL.md.tmpl +++ b/SKILL.md.tmpl @@ -97,7 +97,7 @@ quality gates that produce better results than answering inline. - User asks to plan, preregister, price or pilot a paid eval → invoke `/eval-plan` - User asks what gstack has learned, "show learnings" → invoke `/learn` - User asks to tune question sensitivity, "stop asking me that" → invoke `/plan-tune` -- User asks for code quality dashboard, "health check" → invoke `/health` +- User asks for code quality dashboard, "health check" → invoke `/health`{{VOICE_TRIGGER_ROUTING}} **Route only to skills in your available-skills list.** A rule above whose skill is not listed there is turned off on this install; skip it, never try to invoke it. diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 34f3e0f82..bcddc1d61 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -25,6 +25,7 @@ import { HOST_PATHS } from './resolvers/types'; import { RESOLVERS } from './resolvers/index'; import { rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections'; import { insertRuntimePreludes } from './resolvers/runtime-root'; +import { extractVoiceTriggers } from './resolvers/voice-triggers'; import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index'; import type { HostConfig } from './host-config'; @@ -209,30 +210,6 @@ function rewriteSectionBase(content: string, linkRoot: string | null): string { // ─── Voice Trigger Processing ──────────────────────────────── -/** - * Extract voice-triggers YAML list from frontmatter. - * Returns an array of trigger strings, or [] if no voice-triggers field. - */ -function extractVoiceTriggers(content: string): string[] { - const fmStart = content.indexOf('---\n'); - if (fmStart !== 0) return []; - const fmEnd = content.indexOf('\n---', fmStart + 4); - if (fmEnd === -1) return []; - const frontmatter = content.slice(fmStart + 4, fmEnd); - - const triggers: string[] = []; - let inVoice = false; - for (const line of frontmatter.split('\n')) { - if (/^voice-triggers:/.test(line)) { inVoice = true; continue; } - if (inVoice) { - const m = line.match(/^\s+-\s+"(.+)"$/); - if (m) triggers.push(m[1]); - else if (!/^\s/.test(line)) break; - } - } - return triggers; -} - /** * Preprocess voice triggers: fold voice-triggers YAML field into description, * then strip the field from frontmatter. Must run BEFORE transformFrontmatter @@ -273,7 +250,8 @@ export { extractVoiceTriggers, processVoiceTriggers }; // into a one-line catalog entry (lead sentence + "(gstack)") that stays in // the frontmatter and drops the routing/voice prose. The body is not a home // for it: Claude Code loads a skill body only after the skill is invoked, so -// routing text there can no longer route anything. +// routing text there can no longer route anything. Voice triggers live on in +// the root router's routing block ({{VOICE_TRIGGER_ROUTING}}). // // Opt-out: `--catalog-mode=full` keeps v1.44 behavior (no trim, full // description in frontmatter). Use when debugging routing regressions or diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index cf80ccd40..d02bdae93 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -54,12 +54,14 @@ import { generateSidewaysSweep, generateFlakeFixNumber } from './sideways'; import { generateCoordinatorContract } from './coordinator-contract'; import { generateAutoplanDriverCodes, generateAutoplanDriverCommands, generateAutoplanDriverFiles, generateAutoplanDriverGate, generateAutoplanDriverPhases, generateAutoplanDriverResultFormat } from './autoplan-driver'; import { generateQAScope, generateQAExploratory, generateQAFunctional, generateQAResource, generateQAReview, generateQAReviewPreflight, generateQAMethodReads } from './qa'; +import { generateVoiceTriggerRouting } from './voice-triggers'; export const RESOLVERS: Record = { AUTOPLAN_PUBLICATION_HOOK: generateAutoplanPublicationHook, CEO_MODE_HANDOFF_HOOK: generateCeoModeHandoffHook, OUTSIDE_SELF_GUARD: (ctx, args) => outsideVoiceGuard({ ...ctx, host: args?.[0] === 'claude-code' ? 'codex' : 'claude' }), OUTSIDE_VOICE_ROUTING: generateOutsideVoiceRouting, + VOICE_TRIGGER_ROUTING: generateVoiceTriggerRouting, OUTSIDE_LABEL: (ctx) => outsideVoiceFor(ctx).label, NATIVE_LABEL: (ctx) => outsideVoiceFor(ctx).nativeLabel, OUTSIDE_PROVIDER: (ctx) => outsideVoiceFor(ctx).id, diff --git a/scripts/resolvers/voice-triggers.ts b/scripts/resolvers/voice-triggers.ts new file mode 100644 index 000000000..da652833b --- /dev/null +++ b/scripts/resolvers/voice-triggers.ts @@ -0,0 +1,61 @@ +/** + * Voice triggers: speech-to-text aliases a template lists under + * `voice-triggers:` in its frontmatter. + * + * Every host folds them into the skill's description, but Claude's catalog trim + * keeps only the lead sentence. {{VOICE_TRIGGER_ROUTING}} gives them a cheap home + * in the root router's routing block instead: one routing line per skill. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { TemplateContext } from './types'; +import { discoverTemplates, includesSkill } from '../discover-skills'; +import { getHostConfig } from '../../hosts/index'; + +const ROOT = path.resolve(import.meta.dir, '..', '..'); + +/** + * Extract voice-triggers YAML list from frontmatter. + * Returns an array of trigger strings, or [] if no voice-triggers field. + */ +export function extractVoiceTriggers(content: string): string[] { + const fmStart = content.indexOf('---\n'); + if (fmStart !== 0) return []; + const fmEnd = content.indexOf('\n---', fmStart + 4); + if (fmEnd === -1) return []; + const frontmatter = content.slice(fmStart + 4, fmEnd); + + const triggers: string[] = []; + let inVoice = false; + for (const line of frontmatter.split('\n')) { + if (/^voice-triggers:/.test(line)) { inVoice = true; continue; } + if (inVoice) { + const m = line.match(/^\s+-\s+"(.+)"$/); + if (m) triggers.push(m[1]); + else if (!/^\s/.test(line)) break; + } + } + return triggers; +} + +/** + * Claude only: other hosts keep the voice triggers in each skill's description. + * The template places this at the end of the last routing line, so each line + * starts with its own newline and an empty result leaves the list unchanged. + */ +export function generateVoiceTriggerRouting(ctx: TemplateContext): string { + if (ctx.host !== 'claude') return ''; + const hostConfig = getHostConfig(ctx.host); + let out = ''; + // Sorted so the render doesn't depend on filesystem iteration order. + const skillDirs = discoverTemplates(ROOT).map(t => path.dirname(t.tmpl)).sort(); + for (const skillDir of skillDirs) { + if (skillDir === '.' || !includesSkill(hostConfig, skillDir)) continue; + const content = fs.readFileSync(path.join(ROOT, skillDir, 'SKILL.md.tmpl'), 'utf-8').replace(/\r\n/g, '\n'); + const triggers = extractVoiceTriggers(content); + if (triggers.length === 0) continue; + out += `\n- Voice trigger ${triggers.map(t => `"${t}"`).join(', ')} → invoke \`/${skillDir}\``; + } + return out; +} diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index da11616e6..70fb80cf7 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -4451,6 +4451,21 @@ describe('voice-triggers processing', () => { expect(content).toContain('Voice triggers (speech-to-text aliases):'); }); + test('Claude root router routes every skill voice trigger the catalog trim drops', () => { + const routing = between(fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'), '**Routing rules', '**Route only to skills'); + const skills = fs.readdirSync(ROOT).filter(d => fs.existsSync(path.join(ROOT, d, 'SKILL.md.tmpl'))); + let routed = 0; + for (const skill of skills) { + const triggers = extractVoiceTriggers(fs.readFileSync(path.join(ROOT, skill, 'SKILL.md.tmpl'), 'utf-8').replace(/\r\n/g, '\n')); + if (triggers.length === 0) continue; + const line = routing.split('\n').find(l => l.endsWith(`→ invoke \`/${skill}\``) && l.startsWith('- Voice trigger ')); + expect(line, skill).toBeDefined(); + for (const t of triggers) expect(line, skill).toContain(`"${t}"`); + routed++; + } + expect(routed).toBeGreaterThan(0); + }); + test('generated CSO SKILL.md does NOT contain raw voice-triggers field', () => { const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8'); const fmEnd = content.indexOf('\n---', 4);