|
#!/usr/bin/env bun |
|
/** |
|
* SkillEnforcer.hook.ts - Deterministic Skill Surfacing (UserPromptSubmit) |
|
* |
|
* PURPOSE: |
|
* Matches user prompts against skill triggers using deterministic pattern |
|
* matching. Surfaces matched skills as context suggestions - does NOT |
|
* enforce or mandate skill invocation. |
|
* |
|
* TRIGGER: UserPromptSubmit |
|
* |
|
* INPUT: |
|
* - stdin: JSON with { prompt: string } |
|
* |
|
* OUTPUT: |
|
* - stdout: <system-reminder> with matched skill suggestion(s) |
|
* - exit(0): Always (non-blocking) |
|
* |
|
* PATTERN MATCHING ORDER: |
|
* 1. Command patterns (/skill-name) - exact match, highest priority |
|
* 2. Keyword patterns (create skill) - exact phrase match |
|
* 3. Fuzzy patterns (deploy) - contains match, lowest priority |
|
* |
|
* PERFORMANCE: |
|
* - Non-blocking: Yes |
|
* - Typical execution: <10ms |
|
* - No AI inference: Pure pattern matching |
|
* |
|
* INTER-HOOK RELATIONSHIPS: |
|
* - DEPENDS ON: LoadContext (builds registry at session start) |
|
* - MUST RUN BEFORE: Other UserPromptSubmit hooks |
|
* - COORDINATES WITH: None |
|
*/ |
|
|
|
import { readFileSync, existsSync } from 'fs'; |
|
import { |
|
matchTriggers, |
|
deserializeRegistry, |
|
REGISTRY_PATH, |
|
type SkillTrigger, |
|
type SkillRegistry, |
|
} from './lib/skill-registry'; |
|
|
|
// --- Output Formatting --- |
|
|
|
/** |
|
* Format a single skill suggestion as system-reminder |
|
*/ |
|
export function formatSuggestion(trigger: SkillTrigger): string { |
|
return `<system-reminder> |
|
SKILL SUGGESTION: The **${trigger.skillName}** skill may help with this request. |
|
To invoke: Use the Skill tool with \`skill: "${trigger.skillName}"\` |
|
Matched: "${trigger.pattern}" (${trigger.type}, priority ${trigger.priority}) |
|
</system-reminder>`; |
|
} |
|
|
|
/** |
|
* Format multiple skill suggestions as system-reminder |
|
*/ |
|
export function formatMultipleSuggestions(triggers: SkillTrigger[]): string { |
|
if (triggers.length === 0) { |
|
return ''; |
|
} |
|
|
|
if (triggers.length === 1) { |
|
return formatSuggestion(triggers[0]); |
|
} |
|
|
|
const lines = triggers.map((t, i) => |
|
`${i + 1}. **${t.skillName}** - matched "${t.pattern}" (${t.type})` |
|
); |
|
|
|
return `<system-reminder> |
|
SKILL SUGGESTIONS: Multiple skills may be relevant: |
|
${lines.join('\n')} |
|
Review and invoke the most appropriate skill. |
|
</system-reminder>`; |
|
} |
|
|
|
// --- Main Hook Logic --- |
|
|
|
async function main() { |
|
try { |
|
// Read prompt from stdin |
|
const input = await Bun.stdin.text(); |
|
|
|
if (!input.trim()) { |
|
// No input, silent exit |
|
process.exit(0); |
|
} |
|
|
|
let prompt: string; |
|
try { |
|
const parsed = JSON.parse(input); |
|
prompt = parsed.prompt || ''; |
|
} catch { |
|
// If not JSON, treat the input as the prompt directly |
|
prompt = input.trim(); |
|
} |
|
|
|
if (!prompt) { |
|
process.exit(0); |
|
} |
|
|
|
// Check if registry exists |
|
if (!existsSync(REGISTRY_PATH)) { |
|
// No registry yet, silent passthrough |
|
process.exit(0); |
|
} |
|
|
|
// Load cached registry |
|
let registry: SkillRegistry; |
|
try { |
|
const content = readFileSync(REGISTRY_PATH, 'utf-8'); |
|
registry = deserializeRegistry(JSON.parse(content)); |
|
} catch (error) { |
|
// Invalid registry, silent passthrough |
|
console.error('⚠️ SkillEnforcer: Failed to load registry'); |
|
process.exit(0); |
|
} |
|
|
|
// Match triggers (with deduplication by skill name) |
|
const matches = matchTriggers(prompt, registry, { dedupe: true }); |
|
|
|
if (matches.length === 0) { |
|
// No matches, silent passthrough |
|
process.exit(0); |
|
} |
|
|
|
// Output suggestion(s) |
|
const output = formatMultipleSuggestions(matches); |
|
if (output) { |
|
// Debug output to stderr (visible in terminal) |
|
const skillNames = matches.map(m => m.skillName).join(', '); |
|
console.error(`🎯 SkillEnforcer: Matched [${skillNames}] for "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`); |
|
|
|
// Context injection via stdout |
|
console.log(output); |
|
} |
|
|
|
process.exit(0); |
|
} catch (error) { |
|
// Non-blocking: always exit 0 even on errors |
|
console.error('⚠️ SkillEnforcer error:', error); |
|
process.exit(0); |
|
} |
|
} |
|
|
|
// Only run main if this is the entry point (not imported for testing) |
|
if (import.meta.main) { |
|
main(); |
|
} |