feat(gen-skill-docs): keep voice triggers in the root routing block

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 /<skill>" 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 <noreply@anthropic.com>
This commit is contained in:
thanhnguyentung95andClaude Opus 5.5 committed 2026-10-18 09:02:43 +07:00
1 parent 707ccd6f38
commit 645e75b1e8
5 files changed
+82 -26

No files matched your search

+1 -1
View File
@@ -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.
+3 -25
View File
@@ -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
+2
View File
@@ -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<string, ResolverFn> = {
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,
+61
View File
@@ -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;
}
+15
View File
@@ -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);