/** * Skill references in comments — pure logic. * * A comment may reference agent skills by name with a `/` or `$` trigger * (interchangeable). References live in the comment TEXT itself (`$write-better`), * never as separate annotation state, so they survive draft restore, panel * edits, and share round-trips, and deleting the token deletes the reference. * * The catalog comes from `/api/skills` (see utils/skillCatalog.ts). This module * is browser-safe and dependency-free: trigger detection for the composer, * token extraction against a known catalog, and the export rendering hook the * feedback exporters call through a module-level registry seam * (`setSkillCatalogForExport`) whose default — an empty catalog — reproduces * pre-feature behavior byte-for-byte. */ import { REVIEWER_SKILL_AUDIENCE, skillInstructionsIncludedNote, skillsReferencedHeading, type SkillBlockAudience, } from '@plannotator/core/skill-reference-block'; export type SkillRootId = 'claude' | 'codex' | 'universal'; export interface SkillCatalogEntry { name: string; root: SkillRootId; description?: string; /** Skill frontmatter carries `disable-model-invocation: true`: only a human can invoke it. */ humanOnly: boolean; /** Absolute path to the skill directory, when the server provides it. */ dir?: string; } export const SKILL_TRIGGER_CHARS = ['/', '$'] as const; export type SkillTriggerChar = (typeof SKILL_TRIGGER_CHARS)[number]; /** Longest query the composer keeps treating as a skill lookup. */ export const MAX_SKILL_QUERY_LEN = 48; /** Characters a skill name (and thus an in-progress query) may contain. */ const TOKEN_CHARS = /^[A-Za-z0-9._-]*$/; export interface SkillTriggerContext { /** Index of the trigger character in the text. */ start: number; trigger: SkillTriggerChar; /** Text between the trigger character and the caret. */ query: string; } /** * The active skill trigger at `caret`, or null. * * A trigger is a `/` or `$` that starts a word: at position 0 or preceded by * whitespace or `(`. This is what keeps ordinary typing inert — `packages/ui`, * `and/or`, and `a/b` never trigger because their `/` follows a word character. * The query runs from the trigger to the caret and must stay within skill-name * characters, so typing a space (or any other breaking character) ends the * lookup naturally. * * A bare `/` or `$` (empty query) IS a trigger: it opens the full catalog, * matching how slash/skill menus behave in the host agents. The safety story * for a multi-line composer where Enter means newline and Tab means leave the * field lives in the MENU, not here: while the menu is open with NO row * explicitly activated (arrow keys), every key behaves exactly as if the menu * were closed — "This costs $" + Enter is a newline, "cd /" + Tab leaves the * field. See useSkillReferenceAutocomplete. */ export function findSkillTrigger(text: string, caret: number): SkillTriggerContext | null { if (caret < 1 || caret > text.length) return null; // Walk back over query characters to the nearest candidate trigger. let start = caret - 1; while (start >= 0 && !SKILL_TRIGGER_CHARS.includes(text[start] as SkillTriggerChar)) { if (caret - start > MAX_SKILL_QUERY_LEN) return null; start--; } if (start < 0) return null; const trigger = text[start] as SkillTriggerChar; const before = start === 0 ? '' : text[start - 1]; if (before !== '' && !/[\s(]/.test(before)) return null; const query = text.slice(start + 1, caret); if (query.length > MAX_SKILL_QUERY_LEN) return null; if (!TOKEN_CHARS.test(query)) return null; return { start, trigger, query }; } /** * Filter the catalog for the picker: case-insensitive, name prefix matches * first, then name substring, then description substring. Stable within each * tier (catalog order is alphabetical from the server). */ export function filterSkillCatalog( catalog: SkillCatalogEntry[], query: string, limit = 50, ): SkillCatalogEntry[] { const q = query.toLowerCase(); if (!q) return catalog.slice(0, limit); const prefix: SkillCatalogEntry[] = []; const substring: SkillCatalogEntry[] = []; const description: SkillCatalogEntry[] = []; for (const entry of catalog) { const name = entry.name.toLowerCase(); if (name.startsWith(q)) prefix.push(entry); else if (name.includes(q)) substring.push(entry); else if (entry.description?.toLowerCase().includes(q)) description.push(entry); } return [...prefix, ...substring, ...description].slice(0, limit); } /** * Well-known single-segment absolute paths (Filesystem Hierarchy Standard * roots). `/run`, `/tmp`, `/etc`… read as filesystem paths in prose, so a * `/`-triggered token with one of these names is never extracted as a skill * reference, even when a skill shares the name. `$name` remains fully * available for such skills, and menu insertion switches a `/` trigger to `$` * for them so an inserted reference always survives extraction. */ const RESERVED_PATH_SEGMENTS = new Set([ 'bin', 'boot', 'dev', 'etc', 'home', 'lib', 'lib64', 'media', 'mnt', 'opt', 'proc', 'root', 'run', 'sbin', 'srv', 'sys', 'tmp', 'usr', 'var', ]); /** * Words that, when they immediately precede a `/`-triggered token on the same * line, mark it as prose rather than a skill reference (reproduced false * positives against real review comments — see #1229 hardening): * * - HTTP method verbs: "a POST /agents endpoint", "a GET /show route" — the * token reads as a route path. * - Modal auxiliaries: "this step could /simplify a lot" — a modal is * followed by a bare verb, so the token reads as an emphasized verb, not a * skill noun. * * Deliberately narrow: ordinary verbs ("use /write-better here") and * line-starting or post-newline tokens are untouched, `$name` is never * affected, and menu insertion switches `/` to `$` when the reference would * land after one of these words (see insertSkillReference), so an inserted * reference always survives extraction. */ const SLASH_PROSE_PRECEDING_WORDS = new Set([ // HTTP method verbs 'get', 'head', 'post', 'put', 'delete', 'options', 'patch', // Modal auxiliaries 'can', 'could', 'may', 'might', 'must', 'shall', 'should', 'will', 'would', ]); /** * True when the character run immediately before `triggerIndex` is same-line * whitespace preceded by one of SLASH_PROSE_PRECEDING_WORDS. A newline (or * any non-space boundary) between the word and the trigger breaks the pairing * — a line-starting `/name` always reads as deliberate. */ function slashTokenReadsAsProse(text: string, triggerIndex: number): boolean { const before = text.slice(0, triggerIndex); const word = /([A-Za-z]+)[ \t]+$/.exec(before)?.[1]; return word !== undefined && SLASH_PROSE_PRECEDING_WORDS.has(word.toLowerCase()); } /** * Replace the in-progress trigger token with the chosen skill (keeping the * trigger character the user typed) plus a trailing space, so the inserted * reference is always cleanly word-bounded. The exceptions to "keep the * typed trigger" all switch `/` to `$` because extraction would otherwise * drop the inserted reference: * - a skill whose name is a well-known absolute path segment (`run`, `tmp`…) * — extraction reads `/run` as a path; * - a trigger immediately preceded by an HTTP verb or modal auxiliary * ("could /simplify") — extraction reads that `/name` as prose (see * SLASH_PROSE_PRECEDING_WORDS). */ export function insertSkillReference( text: string, caret: number, trigger: SkillTriggerContext, skill: SkillCatalogEntry, ): { text: string; caret: number } { const triggerChar = trigger.trigger === '/' && (RESERVED_PATH_SEGMENTS.has(skill.name.toLowerCase()) || slashTokenReadsAsProse(text, trigger.start)) ? '$' : trigger.trigger; const inserted = `${triggerChar}${skill.name} `; return { text: text.slice(0, trigger.start) + inserted + text.slice(caret), caret: trigger.start + inserted.length, }; } /** One skill-reference occurrence in a text, with its exact span. */ export interface SkillReferenceToken { /** Index of the trigger character. */ start: number; /** Index just past the last name character (excludes trailing punctuation). */ end: number; entry: SkillCatalogEntry; } /** * Every skill-reference occurrence in the text, in order, WITH positions and * without dedupe — this is what the composer's highlight overlay paints, so a * name referenced twice highlights twice. * * A reference is a word-starting `/name` or `$name` whose name matches a * catalog entry — an exact-case match wins, with case-insensitive matching as * a fallback so a catalog holding two skills differing only by case (e.g. * `Write-Better` and `write-better` from different roots) never swaps * identities on an explicit pick. Path and prose forms are excluded: * - followed by `/` or `\` — a path continuation (`/docs/foo`, `$HOME/bin`); * - preceded by `](` — a markdown link destination (`[x](/write-better)`); * - a `/` token naming a well-known absolute path segment (`/run`, `/tmp`) — * see RESERVED_PATH_SEGMENTS; `$run` still counts; * - a `/` token immediately preceded on the same line by an HTTP verb or a * modal auxiliary ("GET /show", "could /simplify") — see * SLASH_PROSE_PRECEDING_WORDS; `$show` still counts. * * There is deliberately NO shell-redirect exclusion (`cat /run > out`): the * motivating case is already covered by the reserved-path rule, and excluding * on a following `<`/`>` produced false negatives on ordinary prose * ("use /animate <- this one", "quality: /write-better > everything else"). */ export function findSkillReferenceTokens( text: string, catalog: SkillCatalogEntry[], ): SkillReferenceToken[] { if (!text || catalog.length === 0) return []; // Exact-case entries win; the lowercase map is only a fallback, so two // skills differing only by case never collapse into one identity (an // explicit `$Write-Better` must never resolve to `write-better` — wrong // skill, wrong humanOnly flag, wrong SKILL.md injected). First catalog // entry wins each fallback slot, matching discovery's first-seen precedence. const byExactName = new Map(); const byLowerName = new Map(); for (const s of catalog) { if (!byExactName.has(s.name)) byExactName.set(s.name, s); const lower = s.name.toLowerCase(); if (!byLowerName.has(lower)) byLowerName.set(lower, s); } const lookup = (n: string) => byExactName.get(n) ?? byLowerName.get(n.toLowerCase()); const tokens: SkillReferenceToken[] = []; const re = /(^|[\s(])([$/])([A-Za-z0-9][A-Za-z0-9._-]*)/g; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { const after = text[m.index + m[0].length]; if (after === '/' || after === '\\') continue; // Markdown link destination: `[label](/name)` is a URL, not a reference. if (m[1] === '(' && text[m.index - 1] === ']') continue; // Sentence-final dots are punctuation, not part of the name ("use $humanizer."). const name = m[3].toLowerCase(); const trimmed = m[3].replace(/\.+$/, ''); const trimmedName = trimmed.toLowerCase(); // `/run`-style well-known absolute paths never count (only for `/`). if (m[2] === '/' && (RESERVED_PATH_SEGMENTS.has(name) || RESERVED_PATH_SEGMENTS.has(trimmedName))) continue; // Prose guard (only for `/`): "GET /show" is a route, "could /simplify" // is an emphasized verb — never a skill reference. if (m[2] === '/' && slashTokenReadsAsProse(text, m.index + m[1].length)) continue; const exact = lookup(m[3]); const entry = exact ?? lookup(trimmed); if (!entry) continue; const start = m.index + m[1].length; const nameLen = exact ? m[3].length : trimmed.length; tokens.push({ start, end: start + 1 + nameLen, entry }); } return tokens; } /** * Every skill the text references, in first-appearance order, deduped by name. * Same matching rules as findSkillReferenceTokens above. */ export function extractSkillReferences( text: string, catalog: SkillCatalogEntry[], ): SkillCatalogEntry[] { const found: SkillCatalogEntry[] = []; const seen = new Set(); for (const token of findSkillReferenceTokens(text, catalog)) { if (seen.has(token.entry.name)) continue; seen.add(token.entry.name); found.push(token.entry); } return found; } // --------------------------------------------------------------------------- // Export seam // --------------------------------------------------------------------------- // Module-level registry seam (see packages/ui/CLAUDE.md): the exporters in // utils/parser.ts are pure and cannot fetch, so the app registers the fetched // catalog here once per session. Default (empty) means the exporters emit // nothing extra — hosts without the endpoint are byte-for-byte unchanged. let exportCatalog: SkillCatalogEntry[] = []; export function setSkillCatalogForExport(catalog: SkillCatalogEntry[]): void { exportCatalog = catalog; } export function resetSkillCatalogForExport(): void { exportCatalog = []; } /** * A human-only skill's SKILL.md body, fetched from the server for injection * into exported feedback (see utils/skillCatalog.ts, primeSkillContentsForExport). */ export interface SkillExportContent { /** Verbatim SKILL.md body (frontmatter stripped), possibly truncated. */ content: string; /** True when the server cut the body at its injection bound. */ truncated: boolean; /** Absolute path to the skill directory. */ dir: string; /** Absolute path to SKILL.md. */ path: string; } // Companion registry to the export catalog: bodies of the human-only skills // the session has referenced, fetched lazily. Default (empty) means human-only // references fall back to naming the skill plus its directory. const exportContents = new Map(); export function registerSkillContentForExport(name: string, content: SkillExportContent): void { exportContents.set(name, content); } export function resetSkillContentsForExport(): void { exportContents.clear(); } const humanOnlyExportNote = (audience: SkillBlockAudience): string => `human-invocation-only: you cannot invoke this skill; the ${audience.requester} included it as context`; /** * The structural forms the injection block itself uses: the BEGIN/END markers * and the truncation notice. A skill body line matching one of these could * close our block early (everything after it then reads to the receiving * agent as the reviewer's own words), forge a BEGIN marker for a skill nobody * referenced, or forge a truncation notice pointing at an attacker-chosen * path. Skill bodies are user-installed — routinely from third-party repos — * so lookalike lines are neutralized before injection. Leading whitespace and * case variants count, as do lines dressed in markdown decoration (`> `, * `**`, backticks, `#`), unicode-dash or `=` rules, and invisible format * characters laced through the words: a loose reader would still take every * one of them for markers. The match stays anchored to a leading dash/equals * run (after optional decoration), so the words "BEGIN SKILL INSTRUCTIONS" * appearing mid-sentence in ordinary prose are never touched. */ const MARKER_LOOKALIKE_RE = // Dash run: ASCII hyphen, `=`, the unicode hyphen/dash block U+2010–U+2015 // (incl. en/em dash), and the minus sign U+2212. /^[\s>*_#`]*(?:[-=‐-―−]{2,}\s*(?:BEGIN|END)\s*SKILL\s*INSTRUCTIONS\b|\[\s*Instructions\s*truncated\b)/i; /** * Invisible format characters (Unicode category Cf: zero-width space/joiners * U+200B–U+200D, word joiner U+2060, BOM/zero-width no-break space U+FEFF, * soft hyphens, bidi controls, …). JS `\s` matches none of these, so * `---​BEGIN​SKILL​INSTRUCTIONS` slipped past the old regex * while still reading as a marker to an LLM. They are stripped (removed, not * replaced — an attacker can also lace them INSIDE a word) before matching; * the ORIGINAL line is what gets neutralized. The inter-word `\s*` in the * regex above is what keeps the stripped, jammed-together form matchable. */ const INVISIBLE_FORMAT_CHARS_RE = /\p{Cf}/gu; const NEUTRALIZED_LINE_PREFIX = '[plannotator: the following skill-body line matched an injection marker and was neutralized] '; /** * Neutralize body lines that match our own structural markers, visibly: each * matching line is kept verbatim but prefixed, never silently deleted, so the * line no longer parses as a marker while the reader can still see exactly * what the body contained. Matching runs against the line with invisible * format characters stripped, but the original line is what is prefixed. */ export function neutralizeSkillMarkerLines(body: string): string { return body .split('\n') .map((line) => MARKER_LOOKALIKE_RE.test(line.replace(INVISIBLE_FORMAT_CHARS_RE, '')) ? NEUTRALIZED_LINE_PREFIX + line : line, ) .join('\n'); } /** Instruction block for one injected human-only skill. Unmistakably delimited * (BEGIN/END markers survive any markdown inside the body, unlike a fence) * and labeled so it can never read as the reviewer's own words. Body lines * that imitate the markers are neutralized (see neutralizeSkillMarkerLines) * so the body cannot close the block early or forge a sibling block. */ function injectedSkillSection(name: string, body: SkillExportContent, audience: SkillBlockAudience): string { let out = `--- BEGIN SKILL INSTRUCTIONS: ${name} ---\n`; out += `This skill cannot be invoked by a model, so its instructions are included here at the ${audience.requester}'s request. Follow them when acting on this ${audience.subject}.\n`; out += `Skill directory: ${body.dir}\n`; out += `Resolve any relative paths in the instructions below (e.g. references/, scripts/, assets/) against that absolute directory; your working directory is not the skill directory.\n\n`; out += `${neutralizeSkillMarkerLines(body.content)}\n`; if (body.truncated) { out += `\n[Instructions truncated: this is not the full skill. Read the rest at ${body.path}]\n`; } out += `--- END SKILL INSTRUCTIONS: ${name} ---\n`; return out; } /** * The export block for a comment's skill references, or '' when the comment * references none (or no catalog was registered). Appended to the comment in * the exported feedback so the acting agent knows which skills to apply. * * Model-invocable skills export as names — the agent can fetch those itself. * Human-only skills cannot be invoked by the agent, so their SKILL.md body is * injected verbatim (when the session fetched it): a human referencing a * human-only skill IS the human invocation. When no body is available the * skill falls back to its name plus its directory, and to the plain context * note when not even the directory is known. Never throws, never emits a * partial block. * * `injectedNames` is an optional per-export dedupe: exporters thread one Set * through every comment of a single export so a skill's instructions are * injected once, and later references point at the earlier injection. * * `options.external` marks a comment that was NOT written by the reviewer in * this UI — it arrived through the unauthenticated external-annotations API * (annotations carrying a `source`), which any local process can post to. * The whole justification for injection is that a human referencing a * human-only skill IS the human invocation, so a tool-submitted comment must * never cause it: external comments still LIST their skill references, but a * human-only reference falls back to naming the skill plus its directory * (the same shape as the content-unavailable path) instead of injecting. */ export function skillReferenceExportBlock( text: string | undefined, injectedNames?: Set, options?: { external?: boolean }, ): string { if (!text) return ''; const refs = extractSkillReferences(text, exportCatalog); return renderSkillReferencesBlock(refs, { contentOf: (name) => exportContents.get(name), injectedNames, external: options?.external, }); } /** What `renderSkillReferencesBlock` needs besides the references themselves. */ export interface SkillReferencesBlockOptions { /** The SKILL.md body of a human-only skill, when it is to be injected; undefined falls back to the name + directory line. */ contentOf?: (name: string) => SkillExportContent | undefined; /** Per-export dedupe (see skillReferenceExportBlock). */ injectedNames?: Set; /** A comment that did not come from the person in this UI: never injects (see skillReferenceExportBlock). */ external?: boolean; /** Who is asking, about what. Default: the reviewer, about this feedback (Plannotator's export). */ audience?: SkillBlockAudience; } /** * The block itself, for references a caller already resolved: the export * above per comment, and a host that composes ONE block per message (the * Plannotator Inbox, with `INBOX_REPLY_SKILL_AUDIENCE`). '' when `refs` is * empty. Same rules and markers as skillReferenceExportBlock. */ export function renderSkillReferencesBlock( refs: readonly SkillCatalogEntry[], options: SkillReferencesBlockOptions = {}, ): string { if (refs.length === 0) return ''; const { injectedNames } = options; const audience = options.audience ?? REVIEWER_SKILL_AUDIENCE; const external = options.external === true; const toInject: Array<{ name: string; body: SkillExportContent }> = []; let out = `${skillsReferencedHeading(audience)}\n`; for (const ref of refs) { if (!ref.humanOnly) { out += `- \`${ref.name}\`\n`; continue; } const body = options.contentOf?.(ref.name); if (body && injectedNames?.has(ref.name)) { // Already injected earlier in this export (necessarily by a reviewer- // written comment) — true regardless of who references it now. out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions are included earlier in this ${audience.subject})\n`; } else if (external) { // Tool-submitted comment: name + directory, never verbatim injection. out += ref.dir ? `- \`${ref.name}\` (cannot be invoked by a model; this comment came from an external tool, so its instructions are not included — SKILL.md is in ${ref.dir})\n` : `- \`${ref.name}\` (${humanOnlyExportNote(audience)})\n`; } else if (body) { injectedNames?.add(ref.name); toInject.push({ name: ref.name, body }); out += `- \`${ref.name}\` (cannot be invoked by a model; ${skillInstructionsIncludedNote(audience)})\n`; } else if (ref.dir) { out += `- \`${ref.name}\` (cannot be invoked by a model; its instructions could not be included, read SKILL.md in ${ref.dir} and follow it)\n`; } else { out += `- \`${ref.name}\` (${humanOnlyExportNote(audience)})\n`; } } for (const { name, body } of toInject) { out += `\n${injectedSkillSection(name, body, audience)}`; } return out; }