From 645e75b1e8d25554eaf3a54e234f584b07cb267a Mon Sep 17 00:00:00 2001 From: thanhnguyentung95 <17350411+thanhnguyentung95@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:02:43 +0700 Subject: [PATCH] feat(gen-skill-docs): keep voice triggers in the root routing block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude catalog trim drops each skill's "Voice triggers (speech-to-text aliases)" line from its description, and the body section that used to hold it is gone. {{VOICE_TRIGGER_ROUTING}} renders them in the root router's routing block instead: one "Voice trigger ... → invoke /" line per skill, read from the templates' voice-triggers lists, so disabled-skill filtering applies to them like any other routing line. Other hosts keep the line in each description and render nothing here. extractVoiceTriggers moves to the new resolver so the generator and the resolver share one parser. Co-Authored-By: Claude Opus 5.5 --- SKILL.md.tmpl | 2 +- scripts/gen-skill-docs.ts | 28 ++----------- scripts/resolvers/index.ts | 2 + scripts/resolvers/voice-triggers.ts | 61 +++++++++++++++++++++++++++++ test/gen-skill-docs.test.ts | 15 +++++++ 5 files changed, 82 insertions(+), 26 deletions(-) create mode 100644 scripts/resolvers/voice-triggers.ts 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);