Files
vercel__vercel-plugin/hooks/user-prompt-submit-skill-inject.mjs
John Lindquist 3a8a8b2bea feat: fix on-demand skill install pipeline, add 7 new engine rules, sync registries
- Fix skill store cross-root slug lookup: installed skills under registrySlug
  names (e.g., next-best-practices) now resolve when queried by engine name
  (e.g., nextjs). Full-body injection rate went from 5% to 78% in evals.

- Fix on-demand install: remove dead hooks.json registrations (library module
  with no entry point), keep inline calls from pretooluse/user-prompt-submit.
  Non-registry skills now dedup normally instead of wastefully reinjectiing
  summary-only content that can never upgrade.

- Add suppressWhenProjectFacts to engine rules: next-upgrade and turbopack
  suppressed in greenfield mode (was 12 useless injections per session).

- Add deriveRegistryInstallSet to orchestrator-install-plan (was missing
  export that crashed on-demand-skill-install on every invocation).

- Sync all engine rule registries from skills.sh API via build-registry.ts.
  22 skills now have registry backing across 11 official Vercel repos.
  Exclude vercel-labs/vercel-plugin and vercel/vercel-plugin self-references.

- Add build:registry to the build pipeline (hooks → registry → manifest).

- Create 7 new engine rules: electron, vercel-composition-patterns,
  vercel-react-native-skills, web-design-guidelines, deploy-to-vercel,
  vercel-react-view-transitions, workflow-init.

- Add .claude/skills/ to .gitignore (installed skill caches).

- Add Verification Checklist to CLAUDE.md.
2026-04-08 22:54:49 -06:00

1112 lines
40 KiB
JavaScript
Executable File

#!/usr/bin/env node
// hooks/src/user-prompt-submit-skill-inject.mts
import { execFileSync } from "child_process";
import { readFileSync, realpathSync } from "fs";
import { resolve } from "path";
import { fileURLToPath } from "url";
import {
appendAuditLog,
listSessionKeys,
pluginRoot as resolvePluginRoot,
readSessionFile,
syncSessionFileFromClaims,
tryClaimSessionKey,
writeSessionFile
} from "./hook-env.mjs";
import { loadSkills, injectSkills, applyCoInjectRules, parseFactSet } from "./pretooluse-skill-inject.mjs";
import { loadRegistrySkillMetadata } from "./registry-skill-metadata.mjs";
import { buildSkillsAddCommand } from "./skills-cli-command.mjs";
import { triggerOnDemandInstall } from "./on-demand-skill-install.mjs";
import {
COMPACTION_REINJECT_MIN_PRIORITY,
parseSeenSkills,
mergeSeenSkillStates,
mergeSeenSkillStatesWithCompactionReset,
buildDocsBlock
} from "./patterns.mjs";
import { normalizePromptText, compilePromptSignals, matchPromptWithReason, scorePromptWithLexical, classifyTroubleshootingIntent, lexicalFallbackMeetsFloor } from "./prompt-patterns.mjs";
import { searchSkills, initializeLexicalIndex } from "./lexical-index.mjs";
import { analyzePrompt } from "./prompt-analysis.mjs";
import { createLogger, logDecision } from "./logger.mjs";
import { trackBaseEvents } from "./telemetry.mjs";
var MAX_SKILLS = 10;
var DEFAULT_INJECTION_BUDGET_BYTES = 1e5;
var MIN_PROMPT_LENGTH = 10;
var PLUGIN_ROOT = resolvePluginRoot();
var SKILL_INJECTION_VERSION = 1;
var ENV_SEEN_SKILLS_KEY = "VERCEL_PLUGIN_SEEN_SKILLS";
var ENV_CONTEXT_COMPACTED_KEY = "VERCEL_PLUGIN_CONTEXT_COMPACTED";
var DEFAULT_PROMPT_MIN_SCORE = 6;
var PROJECT_CONTEXT_PROMPT_SCORE_BOOST = 3;
var DOMINANT_TOPIC_SCORE_THRESHOLD = 600;
var DOMINANT_TOPIC_MIN_SCORE = 50;
var INVESTIGATION_COMPANION_SKILLS = [
"workflow",
"agent-browser-verify",
"vercel-cli"
];
var log = createLogger();
function getSeenSkillsEnv() {
return typeof process.env[ENV_SEEN_SKILLS_KEY] === "string" ? process.env[ENV_SEEN_SKILLS_KEY] : "";
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmptyString(value) {
return typeof value === "string" && value.trim() !== "" ? value : null;
}
function detectPromptHookPlatform(input) {
if ("conversation_id" in input || "cursor_version" in input) {
return "cursor";
}
return "claude-code";
}
function detectPromptHookPlatformFromRaw(raw) {
try {
const parsed = JSON.parse(raw);
if (isRecord(parsed)) {
return detectPromptHookPlatform(parsed);
}
} catch {
}
return "claude-code";
}
function resolvePromptSessionId(input, env) {
return nonEmptyString(input.session_id) ?? nonEmptyString(input.conversation_id) ?? nonEmptyString(env.SESSION_ID);
}
function resolvePromptCwd(input, env) {
const workspaceRoot = Array.isArray(input.workspace_roots) ? input.workspace_roots.find((entry) => typeof entry === "string" && entry.trim() !== "") : null;
return nonEmptyString(input.cwd) ?? (typeof workspaceRoot === "string" ? workspaceRoot : null) ?? nonEmptyString(env.CURSOR_PROJECT_DIR) ?? nonEmptyString(env.CLAUDE_PROJECT_ROOT) ?? process.cwd();
}
function resolvePromptText(input) {
return nonEmptyString(input.prompt) ?? nonEmptyString(input.message) ?? "";
}
function formatEmptyOutput(platform, env) {
if (platform === "cursor") {
const output = { continue: true };
if (env && Object.keys(env).length > 0) {
output.env = env;
}
return JSON.stringify(output);
}
return "{}";
}
function getInjectionBudget() {
const envVal = process.env.VERCEL_PLUGIN_PROMPT_INJECTION_BUDGET;
if (envVal != null && envVal !== "") {
const parsed = parseInt(envVal, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_INJECTION_BUDGET_BYTES;
}
function capturePromptEnvSnapshot(env = process.env) {
return {
[ENV_SEEN_SKILLS_KEY]: env[ENV_SEEN_SKILLS_KEY],
[ENV_CONTEXT_COMPACTED_KEY]: env[ENV_CONTEXT_COMPACTED_KEY]
};
}
function finalizePromptEnvUpdates(platform, before, env = process.env) {
if (platform !== "cursor") return void 0;
const updates = {};
for (const key of [ENV_SEEN_SKILLS_KEY, ENV_CONTEXT_COMPACTED_KEY]) {
const nextValue = env[key];
if (typeof nextValue === "string" && nextValue !== before[key]) {
updates[key] = nextValue;
}
}
return Object.keys(updates).length > 0 ? updates : void 0;
}
function resolvePromptSeenSkillState(sessionId, skillMap) {
const dedupOff = process.env.VERCEL_PLUGIN_HOOK_DEDUP === "off";
const hasFileDedup = !dedupOff && !!sessionId;
const seenEnv = getSeenSkillsEnv();
const seenClaims = hasFileDedup ? listSessionKeys(sessionId, "seen-skills").join(",") : "";
const seenFile = hasFileDedup ? readSessionFile(sessionId, "seen-skills") : "";
const seenStateResult = dedupOff ? {
seenEnv,
seenState: hasFileDedup ? mergeSeenSkillStates(seenFile, seenClaims) : seenEnv,
compactionResetApplied: false,
clearedSkills: []
} : mergeSeenSkillStatesWithCompactionReset(seenEnv, seenFile, seenClaims, {
sessionId: hasFileDedup ? sessionId : void 0,
includeEnv: !hasFileDedup,
skillMap
});
const seenState = seenStateResult.seenState;
if (hasFileDedup) {
writeSessionFile(sessionId, "seen-skills", seenState);
}
return {
dedupOff,
hasFileDedup,
seenClaims,
seenFile,
seenEnv: seenStateResult.seenEnv,
seenState,
compactionResetApplied: seenStateResult.compactionResetApplied,
clearedSkills: seenStateResult.clearedSkills
};
}
function syncPromptSeenSkillClaims(sessionId, loadedSkills) {
for (const skill of loadedSkills) {
tryClaimSessionKey(sessionId, "seen-skills", skill);
}
return syncSessionFileFromClaims(sessionId, "seen-skills");
}
function parsePromptInput(raw, logger, env = process.env) {
const l = logger || log;
const trimmed = (raw || "").trim();
if (!trimmed) {
l.debug("stdin-empty", {});
return null;
}
let input;
try {
const parsed = JSON.parse(trimmed);
if (!isRecord(parsed)) {
l.debug("stdin-not-object", {});
return null;
}
input = parsed;
} catch (err) {
l.issue("STDIN_PARSE_FAIL", "Failed to parse stdin as JSON", "Verify stdin contains valid JSON", { error: String(err) });
return null;
}
const platform = detectPromptHookPlatform(input);
const prompt = resolvePromptText(input);
const sessionId = resolvePromptSessionId(input, env);
const cwd = resolvePromptCwd(input, env);
if (prompt.length < MIN_PROMPT_LENGTH) {
l.debug("prompt-too-short", { length: prompt.length, min: MIN_PROMPT_LENGTH });
return null;
}
l.debug("input-parsed", {
promptLength: prompt.length,
sessionId,
cwd,
platform
});
return { prompt, platform, sessionId, cwd };
}
function parseLikelySkillsEnv(envValue = process.env.VERCEL_PLUGIN_LIKELY_SKILLS) {
if (typeof envValue !== "string" || envValue.trim() === "") {
return /* @__PURE__ */ new Set();
}
return new Set(
envValue.split(",").map((skill) => skill.trim()).filter((skill) => skill.length > 0)
);
}
function getPromptSignalMinScore(skillConfig) {
const minScore = skillConfig?.promptSignals?.minScore;
return typeof minScore === "number" && !Number.isNaN(minScore) ? minScore : DEFAULT_PROMPT_MIN_SCORE;
}
function formatPromptScore(score) {
return Number.isInteger(score) ? String(score) : score.toFixed(1);
}
function extractLexicalScore(reason) {
const match = reason.match(/lexical [^(]*\((?:raw |score )([0-9]+(?:\.[0-9]+)?)/);
return match ? Number(match[1]) : null;
}
function extractBelowThresholdScore(reason) {
const match = reason.match(/below threshold: score (-?[0-9]+(?:\.[0-9]+)?)/);
return match ? Number(match[1]) : null;
}
function applyLexicalFallbackFloor(entry) {
const lexicalScore = extractLexicalScore(entry.reason);
if (lexicalScore == null || lexicalFallbackMeetsFloor(lexicalScore) || entry.score === -Infinity) {
return entry;
}
const exactScore = extractBelowThresholdScore(entry.reason);
const score = exactScore ?? entry.score;
return {
...entry,
score,
matched: score >= entry.minScore,
reason: `${entry.reason}; lexical floor rejected (raw ${formatPromptScore(lexicalScore)} < 20)`
};
}
function applyProjectContextBoost(entry, likelySkills) {
if (!likelySkills.has(entry.skill) || entry.score === -Infinity) {
return entry;
}
const boostedScore = entry.score + PROJECT_CONTEXT_PROMPT_SCORE_BOOST;
const boostReason = `project-context +${PROJECT_CONTEXT_PROMPT_SCORE_BOOST} (${formatPromptScore(entry.score)} -> ${formatPromptScore(boostedScore)})`;
const reason = entry.reason.startsWith("below threshold:") && boostedScore >= entry.minScore ? boostReason : entry.reason ? `${entry.reason}; ${boostReason}` : boostReason;
return {
...entry,
score: boostedScore,
reason,
matched: boostedScore >= entry.minScore
};
}
function applyDominantTopicSuppression(entry, topScore) {
if (!entry.matched || !Number.isFinite(entry.score) || entry.score >= DOMINANT_TOPIC_MIN_SCORE || topScore < DOMINANT_TOPIC_SCORE_THRESHOLD) {
return entry;
}
return {
...entry,
matched: false,
suppressed: true,
reason: `${entry.reason}; suppressed by dominant topic (${formatPromptScore(topScore)} >= ${DOMINANT_TOPIC_SCORE_THRESHOLD}, score < ${DOMINANT_TOPIC_MIN_SCORE})`
};
}
function applyPromptScoreAdjustments(entries, logger) {
const l = logger || log;
const likelySkills = parseLikelySkillsEnv();
const lexicalFloorRejected = [];
const flooredEntries = entries.map((entry) => {
const adjusted = applyLexicalFallbackFloor(entry);
if (adjusted !== entry) {
lexicalFloorRejected.push(entry.skill);
}
return adjusted;
});
const boostedSkills = [];
if (lexicalFloorRejected.length > 0) {
l.debug("prompt-lexical-floor-rejected", {
minRawScore: 20,
rejectedSkills: lexicalFloorRejected
});
}
const boostedEntries = flooredEntries.map((entry) => {
const boosted = applyProjectContextBoost(entry, likelySkills);
if (boosted !== entry) {
boostedSkills.push(entry.skill);
}
return boosted;
});
if (boostedSkills.length > 0) {
l.debug("prompt-project-context-boost", {
boost: PROJECT_CONTEXT_PROMPT_SCORE_BOOST,
boostedSkills
});
}
const topScore = boostedEntries.reduce((max, entry) => {
if (Number.isFinite(entry.score) && entry.score > max) {
return entry.score;
}
return max;
}, -Infinity);
if (topScore < DOMINANT_TOPIC_SCORE_THRESHOLD) {
return boostedEntries;
}
const suppressedSkills = [];
const adjustedEntries = boostedEntries.map((entry) => {
const adjusted = applyDominantTopicSuppression(entry, topScore);
if (adjusted !== entry) {
suppressedSkills.push(entry.skill);
}
return adjusted;
});
if (suppressedSkills.length > 0) {
l.debug("prompt-dominant-topic-suppression", {
topScore,
minScore: DOMINANT_TOPIC_MIN_SCORE,
suppressedSkills
});
}
return adjustedEntries;
}
function sortPromptScoreStates(entries) {
entries.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (b.priority !== a.priority) return b.priority - a.priority;
return a.skill.localeCompare(b.skill);
});
}
function estimatePromptSkillSize(skillConfig) {
return skillConfig?.summary ? Math.max(skillConfig.summary.length * 10, 500) : 500;
}
function rerankPromptAnalysisReport(report, skillMap, maxSkills, budgetBytes) {
const ranked = Object.entries(report.perSkillResults).filter(([, result]) => result.matched).map(([skill, result]) => ({
skill,
score: result.score,
reason: result.reason,
priority: skillMap[skill]?.priority ?? 0,
matched: true,
minScore: getPromptSignalMinScore(skillMap[skill]),
suppressed: result.suppressed
}));
sortPromptScoreStates(ranked);
const dedupDisabled = report.dedupState.strategy === "disabled";
const seenSkills = new Set(report.dedupState.seenSkills);
const filteredByDedup = [];
const afterDedup = ranked.filter((entry) => {
if (!dedupDisabled && seenSkills.has(entry.skill)) {
filteredByDedup.push(entry.skill);
return false;
}
return true;
});
report.dedupState.filteredByDedup = filteredByDedup;
report.droppedByCap = afterDedup.slice(maxSkills).map((entry) => entry.skill);
const droppedByBudget = [];
const selectedSkills = [];
let usedBytes = 0;
for (const entry of afterDedup.slice(0, maxSkills)) {
const estimatedSize = estimatePromptSkillSize(skillMap[entry.skill]);
if (usedBytes + estimatedSize > budgetBytes && selectedSkills.length > 0) {
droppedByBudget.push(entry.skill);
continue;
}
usedBytes += estimatedSize;
selectedSkills.push(entry.skill);
}
report.selectedSkills = selectedSkills;
report.droppedByBudget = droppedByBudget;
}
function applyPromptScoreAdjustmentsToReport(report, skillMap, logger, options) {
const scoredEntries = Object.entries(report.perSkillResults).map(
([skill, result]) => ({
skill,
score: result.score,
reason: result.reason,
priority: skillMap[skill]?.priority ?? 0,
matched: result.matched,
minScore: getPromptSignalMinScore(skillMap[skill]),
suppressed: result.suppressed
})
);
const adjustedEntries = applyPromptScoreAdjustments(scoredEntries, logger);
for (const entry of adjustedEntries) {
report.perSkillResults[entry.skill] = {
score: entry.score,
reason: entry.reason,
matched: entry.matched,
suppressed: entry.suppressed
};
}
rerankPromptAnalysisReport(
report,
skillMap,
options?.maxSkills ?? MAX_SKILLS,
options?.budgetBytes ?? report.budgetBytes
);
return report;
}
function matchPromptSignals(normalizedPrompt, skills, logger, options) {
const l = logger || log;
const lexical = options?.lexical ?? false;
const { skillMap } = skills;
const scoredEntries = [];
const lexicalHits = lexical ? searchSkills(normalizedPrompt) : void 0;
for (const [skill, config] of Object.entries(skillMap)) {
if (!config.promptSignals) continue;
const compiled = compilePromptSignals(config.promptSignals);
if (lexical) {
const lexResult = scorePromptWithLexical(normalizedPrompt, skill, compiled, lexicalHits);
const lexicalFloorRejected = lexResult.source !== "exact" && !lexicalFallbackMeetsFloor(lexResult.lexicalScore);
const isMatched = lexResult.score >= compiled.minScore && !lexicalFloorRejected;
const reason = lexResult.source === "exact" ? matchPromptWithReason(normalizedPrompt, compiled).reason : `${matchPromptWithReason(normalizedPrompt, compiled).reason}; lexical ${lexResult.source} (score ${lexResult.lexicalScore.toFixed(1)}, tier ${lexResult.boostTier ?? "none"})${lexicalFloorRejected ? "; lexical floor rejected" : ""}`;
scoredEntries.push({
skill,
score: lexResult.score,
reason,
priority: config.priority,
matched: isMatched,
minScore: compiled.minScore,
suppressed: lexResult.score === -Infinity
});
} else {
const result = matchPromptWithReason(normalizedPrompt, compiled);
scoredEntries.push({
skill,
matched: result.matched,
score: result.score,
reason: result.reason,
priority: config.priority,
minScore: compiled.minScore,
suppressed: result.score === -Infinity
});
}
}
const adjustedEntries = applyPromptScoreAdjustments(scoredEntries, l);
for (const entry of adjustedEntries) {
l.trace("prompt-signal-eval", {
skill: entry.skill,
matched: entry.matched,
score: entry.score,
reason: entry.reason,
suppressed: entry.suppressed
});
}
const matches = adjustedEntries.filter((entry) => entry.matched).map(({ skill, score, reason, priority }) => ({
skill,
score,
reason,
priority
}));
sortPromptScoreStates(matches);
l.debug("prompt-matches", {
totalWithSignals: Object.values(skillMap).filter((c) => c.promptSignals).length,
matched: matches.map((m) => ({ skill: m.skill, score: m.score })),
lexical
});
return matches;
}
function selectInvestigationCompanion(selectedSkills, perSkillResults) {
if (!selectedSkills.includes("investigation-mode")) {
return { companion: null, reason: "investigation-mode not selected" };
}
let bestCompanion = null;
let bestScore = -Infinity;
for (const candidate of INVESTIGATION_COMPANION_SKILLS) {
const result = perSkillResults[candidate];
if (result && result.matched && result.score > bestScore) {
bestScore = result.score;
bestCompanion = candidate;
}
}
if (!bestCompanion) {
return { companion: null, reason: "no companion scored high enough" };
}
return {
companion: bestCompanion,
reason: `companion "${bestCompanion}" scored ${bestScore}`
};
}
function deduplicateAndInject(matches, skills, logger, platform) {
const l = logger || log;
const dedupOff = process.env.VERCEL_PLUGIN_HOOK_DEDUP === "off";
const seenState = getSeenSkillsEnv();
const injectedSkills = dedupOff ? /* @__PURE__ */ new Set() : parseSeenSkills(seenState);
const budget = getInjectionBudget();
const allMatched = matches.map((m) => m.skill);
const newMatches = dedupOff ? matches : matches.filter((m) => !injectedSkills.has(m.skill));
if (newMatches.length === 0) {
l.debug("all-prompt-matches-deduped", { matched: allMatched, seen: [...injectedSkills] });
return { parts: [], loaded: [], summaryOnly: [], droppedByCap: [], droppedByBudget: [], matchedSkills: allMatched };
}
const rankedSkills = newMatches.slice(0, MAX_SKILLS).map((m) => m.skill);
const droppedByCap = newMatches.slice(MAX_SKILLS).map((m) => m.skill);
l.debug("prompt-dedup", {
rankedSkills,
droppedByCap,
previouslyInjected: [...injectedSkills]
});
const result = injectSkills(rankedSkills, {
pluginRoot: PLUGIN_ROOT,
hasEnvDedup: !dedupOff,
injectedSkills,
budgetBytes: budget,
maxSkills: MAX_SKILLS,
skillMap: skills.skillMap,
logger: l,
platform: platform ?? "claude-code"
});
return {
...result,
droppedByCap: [...result.droppedByCap, ...droppedByCap],
matchedSkills: allMatched
};
}
function formatOutput(parts, matchedSkills, injectedSkills, summaryOnly, droppedByCap, droppedByBudget, promptMatchReasons, skillMap, platform = "claude-code", env) {
if (parts.length === 0) {
return formatEmptyOutput(platform, env);
}
const skillInjection = {
version: SKILL_INJECTION_VERSION,
hookEvent: "UserPromptSubmit",
matchedSkills,
injectedSkills,
summaryOnly,
droppedByBudget
};
const metaComment = `<!-- skillInjection: ${JSON.stringify(skillInjection)} -->`;
const bannerLines = ["[vercel-plugin] Best practices auto-suggested based on prompt analysis:"];
for (const skill of injectedSkills) {
const reason = promptMatchReasons?.[skill];
if (reason) {
bannerLines.push(` - "${skill}" matched: ${reason}`);
} else {
bannerLines.push(` - "${skill}"`);
}
}
const banner = bannerLines.join("\n");
const docsBlock = buildDocsBlock(injectedSkills, skillMap);
const sections = [banner];
if (docsBlock) sections.push(docsBlock);
sections.push(parts.join("\n\n"));
const additionalContext = sections.join("\n\n") + "\n" + metaComment;
if (platform === "cursor") {
const output2 = {
additional_context: additionalContext,
continue: true
};
if (env && Object.keys(env).length > 0) {
output2.env = env;
}
return JSON.stringify(output2);
}
const output = {
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext
}
};
return JSON.stringify(output);
}
function run() {
const timing = {};
const tPhase = log.active ? log.now() : 0;
let raw;
try {
raw = readFileSync(0, "utf-8");
} catch {
return "{}";
}
const platform = detectPromptHookPlatformFromRaw(raw);
const parsed = parsePromptInput(raw, log);
if (!parsed) return formatEmptyOutput(platform);
if (log.active) timing.stdin_parse = Math.round(log.now() - tPhase);
const { prompt, sessionId, cwd } = parsed;
const promptEnvBefore = capturePromptEnvSnapshot();
const normalizedPrompt = normalizePromptText(prompt);
if (!normalizedPrompt) {
log.debug("normalized-prompt-empty", {});
return formatEmptyOutput(platform);
}
const tSkillmap = log.active ? log.now() : 0;
const skills = loadSkills(PLUGIN_ROOT, log, cwd);
if (!skills) return formatEmptyOutput(platform);
if (log.active) timing.skillmap_load = Math.round(log.now() - tSkillmap);
const tAnalyze = log.active ? log.now() : 0;
const seenSkillState = resolvePromptSeenSkillState(sessionId, skills.skillMap);
const { dedupOff, hasFileDedup, seenState } = seenSkillState;
if (seenSkillState.compactionResetApplied) {
log.debug("dedup-compaction-reset", {
sessionId,
threshold: COMPACTION_REINJECT_MIN_PRIORITY,
clearedSkills: seenSkillState.clearedSkills
});
}
const budget = getInjectionBudget();
const lexicalEnabled = process.env.VERCEL_PLUGIN_LEXICAL_PROMPT !== "0";
if (lexicalEnabled) {
initializeLexicalIndex(new Map(Object.entries(skills.skillMap)));
}
const report = analyzePrompt(prompt, skills.skillMap, seenState, budget, MAX_SKILLS, { lexicalEnabled });
applyPromptScoreAdjustmentsToReport(report, skills.skillMap, log, {
maxSkills: MAX_SKILLS,
budgetBytes: budget
});
if (log.active) timing.analyze = Math.round(log.now() - tAnalyze);
log.trace("prompt-analysis-full", report);
for (const [skill, r] of Object.entries(report.perSkillResults)) {
log.debug("prompt-signal-eval", {
skill,
score: r.score,
reason: r.reason,
matched: r.matched,
suppressed: r.suppressed
});
}
log.debug("prompt-selection", {
selectedSkills: report.selectedSkills,
droppedByCap: report.droppedByCap,
droppedByBudget: report.droppedByBudget,
dedupStrategy: report.dedupState.strategy,
filteredByDedup: report.dedupState.filteredByDedup,
budgetBytes: report.budgetBytes,
timingMs: report.timingMs
});
const intentResult = classifyTroubleshootingIntent(normalizedPrompt);
if (intentResult.intent) {
for (const skill of intentResult.skills) {
if (!report.selectedSkills.includes(skill) && report.selectedSkills.length < MAX_SKILLS) {
report.selectedSkills.push(skill);
}
}
logDecision(log, {
hook: "UserPromptSubmit",
event: "troubleshooting_intent_routed",
intent: intentResult.intent,
skills: intentResult.skills,
reason: intentResult.reason,
durationMs: log.active ? log.elapsed() : void 0
});
} else if (intentResult.reason === "suppressed by test framework mention") {
const suppressSet = /* @__PURE__ */ new Set(["verification", "investigation-mode", "agent-browser-verify"]);
const before = report.selectedSkills.length;
report.selectedSkills = report.selectedSkills.filter((s) => !suppressSet.has(s));
if (report.selectedSkills.length < before) {
logDecision(log, {
hook: "UserPromptSubmit",
event: "verification_family_suppressed",
reason: intentResult.reason,
durationMs: log.active ? log.elapsed() : void 0
});
}
}
const investigationSkills = ["investigation-mode", "observability", "workflow"];
const matchedInvestigation = Object.entries(report.perSkillResults).filter(([skill, r]) => r.matched && investigationSkills.includes(skill));
if (matchedInvestigation.length > 0) {
logDecision(log, {
hook: "UserPromptSubmit",
event: "investigation_intent_detected",
reason: "frustration_or_debug_signals",
skills: matchedInvestigation.map(([skill, r]) => ({ skill, score: r.score })),
durationMs: log.active ? log.elapsed() : void 0
});
}
const companionResult = selectInvestigationCompanion(
report.selectedSkills,
report.perSkillResults
);
if (companionResult.companion) {
const companion = companionResult.companion;
const newSelected = ["investigation-mode"];
if (!report.selectedSkills.includes(companion)) {
newSelected.push(companion);
} else {
newSelected.push(companion);
}
report.selectedSkills.length = 0;
report.selectedSkills.push(...newSelected);
logDecision(log, {
hook: "UserPromptSubmit",
event: "companion_selected",
skill: "investigation-mode",
companion,
reason: companionResult.reason,
durationMs: log.active ? log.elapsed() : void 0
});
} else if (report.selectedSkills.length > 1) {
logDecision(log, {
hook: "UserPromptSubmit",
event: "companion_selected",
skill: report.selectedSkills[0],
companion: report.selectedSkills[1],
reason: "multi_skill_prompt_match",
durationMs: log.active ? log.elapsed() : void 0
});
}
const promptProjectFacts = parseFactSet(process.env.VERCEL_PLUGIN_PROJECT_FACTS);
if (promptProjectFacts.size === 0 && sessionId) {
const gf = readSessionFile(sessionId, "greenfield");
if (gf === "true") promptProjectFacts.add("greenfield");
}
if (promptProjectFacts.size > 0) {
const suppressedByFacts = [];
for (const [skill, r] of Object.entries(report.perSkillResults)) {
const cfg = skills.skillMap[skill];
const suppress = cfg?.suppressWhenProjectFacts;
if (suppress && suppress.some((fact) => promptProjectFacts.has(fact))) {
r.matched = false;
r.suppressed = true;
r.reason = `suppressed by project fact: ${suppress.find((f) => promptProjectFacts.has(f))}`;
suppressedByFacts.push(skill);
}
}
if (suppressedByFacts.length > 0) {
report.selectedSkills = report.selectedSkills.filter((s) => !suppressedByFacts.includes(s));
log.debug("suppress-by-project-facts", { suppressed: suppressedByFacts, facts: [...promptProjectFacts] });
}
}
const allMatchedPromptSkills = Object.entries(report.perSkillResults).filter(([, r]) => r.matched).map(([skill]) => skill);
const promptCoInject = applyCoInjectRules({
rankedSkills: report.selectedSkills,
allMatchedSkills: allMatchedPromptSkills,
skillMap: skills.skillMap,
projectFacts: parseFactSet(process.env.VERCEL_PLUGIN_PROJECT_FACTS),
runtimeFacts: /* @__PURE__ */ new Set(),
injectedSkills: dedupOff ? /* @__PURE__ */ new Set() : parseSeenSkills(seenState),
maxSkills: MAX_SKILLS,
dedupOff,
logger: log
});
report.selectedSkills.length = 0;
report.selectedSkills.push(...promptCoInject.rankedSkills);
const allMatched = Object.entries(report.perSkillResults).filter(([, r]) => r.matched).map(([skill]) => skill);
const isGreenfield = sessionId ? readSessionFile(sessionId, "greenfield") === "true" : false;
const hasExplicitPromptMatch = Object.entries(report.perSkillResults).some(
([, r]) => r.matched && r.reason && !r.reason.includes("lexical")
);
if (isGreenfield && !hasExplicitPromptMatch) {
log.complete("greenfield_no_vercel_signals", { matchedCount: allMatched.length }, log.active ? timing : null);
return formatEmptyOutput(platform, finalizePromptEnvUpdates(platform, promptEnvBefore));
}
if (isGreenfield && hasExplicitPromptMatch && cwd && sessionId) {
const alreadyRecommended = readSessionFile(sessionId, "skills-recommended");
if (!alreadyRecommended) {
writeSessionFile(sessionId, "skills-recommended", "true");
const registryMeta = loadRegistrySkillMetadata();
const bundles = [
{
name: "AI App",
description: "AI SDK, Chat SDK, AI Elements, Workflow \u2014 AI-powered features with automatic auth via Vercel AI Gateway",
skills: []
},
{
name: "Web App",
description: "Next.js best practices, React patterns, caching, shadcn/ui components",
skills: []
},
{
name: "Backend / API",
description: "Vercel CLI, deployments, feature flags, turborepo",
skills: []
}
];
const bundleMap = {
"ai-sdk": 0,
"ai-gateway": 0,
"ai-elements": 0,
"chat-sdk": 0,
"workflow": 0,
"nextjs": 1,
"next-cache-components": 1,
"next-upgrade": 1,
"next-forge": 1,
"react-best-practices": 1,
"shadcn": 1,
"vercel-cli": 2,
"deployments-cicd": 2,
"turborepo": 2,
"vercel-flags": 2,
"vercel-sandbox": 2,
"agent-browser": 2
};
for (const [name] of Object.entries(skills.skillMap)) {
const meta = registryMeta.get(name);
if (!meta?.registry) continue;
const bundleIdx = bundleMap[name];
if (bundleIdx === void 0) continue;
bundles[bundleIdx].skills.push({
name,
registry: meta.registry,
installSlug: meta.registrySlug ?? name
});
}
const bundleLines = bundles.filter((b) => b.skills.length > 0).map((b, i) => {
const skillNames = b.skills.map((s) => s.name).join(", ");
return `${i + 1}. **${b.name}** \u2014 ${b.description}
Skills: ${skillNames}`;
}).join("\n");
const bundleCommands = bundles.filter((b) => b.skills.length > 0).map((b, i) => {
const groups = /* @__PURE__ */ new Map();
for (const s of b.skills) {
const group = groups.get(s.registry) ?? [];
group.push(s.installSlug);
groups.set(s.registry, group);
}
const cmds = [];
for (const [registry, slugs] of groups) {
const cmd = buildSkillsAddCommand(registry, slugs, "claude-code");
if (cmd) cmds.push(cmd.printable);
}
return `Starter ${i + 1} (${b.name}): ${cmds.join(" && ")}`;
}).join("\n");
const nonInteractive = process.env.VERCEL_PLUGIN_NONINTERACTIVE === "1";
const matchedSkillNames = new Set(allMatched);
const scaffoldingLines = [];
if (matchedSkillNames.has("nextjs") || matchedSkillNames.has("next-cache-components")) {
scaffoldingLines.push(
"**Scaffold first**: Run `npx create-next-app@latest .` (with flags like `--ts --tailwind --eslint --app --src-dir`) to initialize the project.",
"This ensures the latest Next.js version with correct deps. Do NOT manually write package.json or config files."
);
}
if (matchedSkillNames.has("ai-sdk")) {
scaffoldingLines.push(
"**AI SDK**: After scaffolding, add the AI SDK with `npm install ai @ai-sdk/openai` (or the relevant provider package)."
);
}
const scaffoldingBlock = scaffoldingLines.length > 0 ? `### Project Scaffolding
${scaffoldingLines.join("\n")}
` : "";
const setupNote = `
RECOMMENDED SETUP: After installing skills, run \`vercel link\` to connect to a Vercel project, then \`vercel env pull\` to get OIDC credentials. This enables AI Gateway routing with automatic auth \u2014 no API keys needed.
`;
const discoveryPrompt = nonInteractive ? `Vercel plugin is active.
${scaffoldingBlock}Recommended starters for this task:
${bundleLines}
Install commands:
${bundleCommands}
Install the most relevant starters from the project root before writing code.
After installing, skill guidance will be loaded automatically.
${setupNote}` : `Vercel plugin is active.
${scaffoldingBlock}I can preload relevant guidance for this project.
Recommended starters:
${bundleLines}
Ask the user: "Which starters would you like? Type the numbers (e.g. 1,2), 'all', or just keep going and I'll stay lightweight."
Install commands:
${bundleCommands}
After the user picks, run the install commands from the project root.
${setupNote}`;
const output = {
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: discoveryPrompt
}
};
return JSON.stringify(output);
}
}
function buildInstallCommandForSlugs(slugs) {
const meta = loadRegistrySkillMetadata();
const groups = /* @__PURE__ */ new Map();
for (const slug of slugs) {
const m = meta.get(slug);
if (!m?.registry) continue;
const installName = m.registrySlug ?? slug;
const group = groups.get(m.registry) ?? [];
group.push(installName);
groups.set(m.registry, group);
}
if (groups.size === 0) return null;
const commands = [];
for (const [registry, installNames] of groups) {
const cmd = buildSkillsAddCommand(registry, installNames, "claude-code");
if (cmd) commands.push(cmd.printable);
}
return commands.join(" && ");
}
if (!isGreenfield && hasExplicitPromptMatch && cwd && sessionId && process.env.VERCEL_PLUGIN_SYNC_INSTALL !== "0") {
const registryMeta = loadRegistrySkillMetadata();
const installedSkills = skills.skillStore?.listInstalledSkills() ?? [];
const installedSet = new Set(installedSkills);
const recommendable = allMatched.filter(
(s) => registryMeta.has(s) && !installedSet.has(s)
);
if (recommendable.length > 0) {
const alreadyRecommended = readSessionFile(sessionId, "skills-recommended");
if (!alreadyRecommended) {
writeSessionFile(sessionId, "skills-recommended", recommendable.join(","));
const nonInteractive = process.env.VERCEL_PLUGIN_NONINTERACTIVE === "1";
if (nonInteractive) {
const groups = /* @__PURE__ */ new Map();
for (const slug of recommendable) {
const meta = registryMeta.get(slug);
if (!meta?.registry) continue;
const installName = meta.registrySlug ?? slug;
const group = groups.get(meta.registry) ?? [];
group.push(installName);
groups.set(meta.registry, group);
}
for (const [registry, installNames] of groups) {
const cmd = buildSkillsAddCommand(registry, installNames, "claude-code");
if (!cmd) continue;
try {
log.debug("prompt-skill-auto-install-start", { registry, skills: installNames, cwd });
execFileSync(cmd.file, cmd.args, {
cwd,
timeout: 3e4,
env: { ...process.env, CI: "1" },
maxBuffer: 1024 * 1024,
stdio: "pipe"
});
log.debug("prompt-skill-auto-install-success", { registry, skills: installNames });
} catch (error) {
log.debug("prompt-skill-auto-install-failed", { registry, error: String(error) });
}
}
} else {
const skillList = recommendable.map((s, i) => {
const meta = registryMeta.get(s);
const desc = skills.skillMap[s]?.summary || "";
return `${i + 1}. **${s}**${desc ? ` \u2014 ${desc}` : ""}`;
}).join("\n");
const installCmd = buildInstallCommandForSlugs(recommendable);
const recommendPrompt = `<CRITICAL>
I detected Vercel skills that match your request. Before proceeding, ask the user:
"I found ${recommendable.length} relevant Vercel skill${recommendable.length > 1 ? "s" : ""} for this task:
${skillList}
Would you like me to install them? (yes for all, or pick by number)"
If they agree, run from the project root:
${installCmd}
Do not skip this step.
</CRITICAL>`;
const output = {
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: recommendPrompt
}
};
return JSON.stringify(output);
}
}
}
}
if (allMatched.length === 0) {
log.debug("prompt-analysis-issue", {
issue: "no_prompt_matches",
evaluatedSkills: Object.keys(report.perSkillResults),
suppressedSkills: Object.entries(report.perSkillResults).filter(([, r]) => r.suppressed).map(([skill]) => skill)
});
log.complete("no_prompt_matches", { matchedCount: 0 }, log.active ? timing : null);
return formatEmptyOutput(platform, finalizePromptEnvUpdates(platform, promptEnvBefore));
}
if (report.selectedSkills.length === 0) {
log.debug("prompt-analysis-issue", {
issue: "all_deduped",
matchedSkills: allMatched,
seenSkills: report.dedupState.seenSkills,
dedupStrategy: report.dedupState.strategy
});
log.complete("all_deduped", {
matchedCount: allMatched.length,
dedupedCount: allMatched.length
}, log.active ? timing : null);
return formatEmptyOutput(platform, finalizePromptEnvUpdates(platform, promptEnvBefore));
}
const tInject = log.active ? log.now() : 0;
const injectedSkills = dedupOff ? /* @__PURE__ */ new Set() : parseSeenSkills(seenState);
const injectResult = injectSkills(report.selectedSkills, {
pluginRoot: PLUGIN_ROOT,
projectRoot: cwd,
skillStore: skills.skillStore,
hasEnvDedup: !dedupOff,
sessionId,
injectedSkills,
budgetBytes: budget,
maxSkills: MAX_SKILLS,
skillMap: skills.skillMap,
logger: log,
platform
});
if (log.active) timing.inject = Math.round(log.now() - tInject);
const { parts, loaded, summaryOnly, manifestSummaryOnly } = injectResult;
if (manifestSummaryOnly && manifestSummaryOnly.length > 0 && sessionId && cwd) {
const onDemandResult = triggerOnDemandInstall({
summaryOnlySkills: manifestSummaryOnly,
sessionId,
projectRoot: cwd,
pluginRoot: PLUGIN_ROOT,
logger: log
});
if (onDemandResult.triggered.length > 0) {
log.debug("on-demand-install-triggered", {
triggered: onDemandResult.triggered,
alreadyAttempted: onDemandResult.alreadyAttempted,
noRegistry: onDemandResult.noRegistry
});
}
}
let syncedSeenSkills = seenState;
if (hasFileDedup) {
syncedSeenSkills = syncPromptSeenSkillClaims(sessionId, loaded);
}
const droppedByCap = [...injectResult.droppedByCap, ...report.droppedByCap];
const droppedByBudget = [...injectResult.droppedByBudget, ...report.droppedByBudget];
const matchedSkills = allMatched;
if (parts.length === 0) {
log.complete("all_deduped", {
matchedCount: matchedSkills.length,
dedupedCount: matchedSkills.length
}, log.active ? timing : null);
return formatEmptyOutput(platform);
}
if (log.active) timing.total = log.elapsed();
log.complete("injected", {
matchedCount: matchedSkills.length,
injectedCount: loaded.length,
dedupedCount: matchedSkills.length - loaded.length - droppedByCap.length - droppedByBudget.length,
cappedCount: droppedByCap.length + droppedByBudget.length
}, log.active ? timing : null);
if (loaded.length > 0) {
appendAuditLog({
event: "prompt-skill-injection",
hookEvent: "UserPromptSubmit",
matchedSkills,
injectedSkills: loaded,
summaryOnly,
droppedByCap,
droppedByBudget
}, cwd);
}
if (sessionId && loaded.length > 0) {
const telemetryEntries = [];
for (const skill of loaded) {
const r = report.perSkillResults[skill];
telemetryEntries.push(
{ key: "prompt:skill", value: skill },
{ key: "prompt:score", value: String(r?.score ?? 0) },
{ key: "prompt:hook", value: "UserPromptSubmit" }
);
}
trackBaseEvents(sessionId, telemetryEntries).catch(() => {
});
}
let outputEnv;
const envFile = nonEmptyString(process.env.CLAUDE_ENV_FILE);
const seenSkills = hasFileDedup ? syncedSeenSkills : seenState;
if (platform === "cursor") {
if (!envFile) {
process.env[ENV_SEEN_SKILLS_KEY] = seenSkills;
}
outputEnv = finalizePromptEnvUpdates(platform, promptEnvBefore);
}
const promptMatchReasons = {};
for (const skill of loaded) {
const r = report.perSkillResults[skill];
if (r?.reason) {
promptMatchReasons[skill] = r.reason;
}
}
return formatOutput(
parts,
matchedSkills,
loaded,
summaryOnly,
droppedByCap,
droppedByBudget,
promptMatchReasons,
skills.skillMap,
platform,
outputEnv
);
}
function isMainModule() {
try {
const scriptPath = realpathSync(resolve(process.argv[1] || ""));
const modulePath = realpathSync(fileURLToPath(import.meta.url));
return scriptPath === modulePath;
} catch {
return false;
}
}
if (isMainModule()) {
try {
const output = run();
process.stdout.write(output);
} catch (err) {
const entry = [
`[${(/* @__PURE__ */ new Date()).toISOString()}] CRASH in user-prompt-submit-skill-inject.mts`,
` error: ${err?.message || String(err)}`,
` stack: ${err?.stack || "(no stack)"}`,
` PLUGIN_ROOT: ${PLUGIN_ROOT}`,
` argv: ${JSON.stringify(process.argv)}`,
` cwd: ${process.cwd()}`,
""
].join("\n");
process.stderr.write(entry);
process.stdout.write("{}");
}
}
export {
INVESTIGATION_COMPANION_SKILLS,
deduplicateAndInject,
formatOutput,
matchPromptSignals,
parsePromptInput,
resolvePromptSeenSkillState,
run,
selectInvestigationCompanion,
syncPromptSeenSkillClaims
};