Skip to content

AlgorithmNudge skill routing is silently mute: index staleness ignores content, and matchSkills assumes English morphology #1787

Description

@xmasyx

Summary

hooks/AlgorithmNudge.hook.ts carries the always-on skill-routing nudge ("this prompt matches USE WHEN of X — invoke the skill rather than handrolling"). Two independent defects make it silently mute. Both were measured on a current install; neither raises an error, and both fail in the direction that matters: the nudge exists to catch a capability you forgot, so a silent miss is the entire failure mode.

1. Index staleness is measured in time, not in content

loadIndex() rebuilds only when the index is older than INDEX_MAX_AGE_MS (6h):

const stale = !idx || Date.now() - idx.builtAt > INDEX_MAX_AGE_MS;

A skill created just after a rebuild is therefore invisible to routing for up to six hours. Measured: a fresh skill on disk while the index held 58 entries and did not contain it; every other check (skill list, invocation, hygiene gate) passed, so nothing indicated a problem.

Suggested fix — also compare the newest SKILL.md mtime, one readdir plus N stat:

function newestSkillMtime(): number {
  let newest = 0;
  try {
    for (const dir of readdirSync(SKILLS_DIR)) {
      try { const t = statSync(join(SKILLS_DIR, dir, 'SKILL.md')).mtimeMs; if (t > newest) newest = t; }
      catch { /* no SKILL.md here */ }
    }
  } catch { /* no skills dir */ }
  return newest;
}

const stale = !idx
  || Date.now() - idx.builtAt > INDEX_MAX_AGE_MS
  || newestSkillMtime() > idx.builtAt;

2. matchSkills assumes English morphology

Single-word triggers are matched with an ASCII word boundary:

new RegExp(`\\b${escaped}\\b`).test(p)

Two consequences for any non-English install:

  • \b is ASCII-only in JS. A trigger containing an accented character never matches. Measured: trigger università, prompt la università di Milano → no match.
  • Inflected languages need stems. English adjectives barely inflect, so one listed form covers most prompts. Italian, Spanish, French and German inflect every adjective: a trigger burocratico does not fire on burocratica or burocratici, and listing every form burns the 1024-char description budget.

Suggested fix — Unicode lookarounds, plus an opt-in trailing * meaning "stem". Backwards compatible: no existing description contains *, so no current behaviour changes.

const isStem = phrase.endsWith('*');
const core = isStem ? phrase.slice(0, -1) : phrase;
if (core.length >= 5) {
  const esc = core.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const L = '\\p{L}\\p{N}';
  const tail = isStem ? `[${L}]{0,4}` : '';
  if (new RegExp(`(?<![${L}])${esc}${tail}(?![${L}])`, 'u').test(p)) { /* match */ }
}

parseUseWhen must stop stripping the trailing * as punctuation.

Verification

Both fixes applied locally and probed with two poles each.

Case before after
SKILL.md written after last rebuild is indexed no yes
università trigger vs la università di Milano no match match
burocratic* vs questa lettera suona burocratica no match match
burocratic* vs la burocrazia italiana (over-trigger control) no match no match
11 unrelated prompts (over-trigger control) 0 fire 0 fire

Reverting either patch turns its probe red, so the checks discriminate rather than merely pass.

Scope

Affects every install whose operator does not prompt in English, and every install during the window after adding a skill. The first defect is language-independent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions