agent-skillboard 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +154 -630
  3. package/docs/adapters.md +96 -96
  4. package/docs/ai-skill-routing-goal.md +112 -0
  5. package/docs/capabilities.md +6 -0
  6. package/docs/install.md +155 -105
  7. package/docs/plans/skillboard-variant-lifecycle-handoff.md +56 -0
  8. package/docs/policy-model.md +266 -214
  9. package/docs/positioning.md +94 -94
  10. package/docs/reference.md +349 -0
  11. package/docs/routing.md +85 -0
  12. package/docs/user-flow.md +75 -16
  13. package/docs/value-proof.md +190 -0
  14. package/docs/variant-lifecycle.md +86 -0
  15. package/docs/versioning.md +157 -138
  16. package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -8
  17. package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -8
  18. package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -8
  19. package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -8
  20. package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -8
  21. package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -8
  22. package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -8
  23. package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -8
  24. package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -8
  25. package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -8
  26. package/examples/skills/grill-me/SKILL.md +9 -9
  27. package/examples/skills/grill-with-docs/SKILL.md +9 -9
  28. package/examples/skills/requirement-intake/SKILL.md +9 -9
  29. package/examples/skills/tdd/SKILL.md +8 -8
  30. package/package.json +25 -20
  31. package/src/advisor/guidance.mjs +232 -0
  32. package/src/advisor/schema.mjs +2 -0
  33. package/src/advisor/skills.mjs +2 -0
  34. package/src/advisor.mjs +36 -7
  35. package/src/brief-cli.mjs +6 -5
  36. package/src/brief-renderer.mjs +225 -8
  37. package/src/cli.mjs +589 -34
  38. package/src/config-helpers.mjs +34 -18
  39. package/src/conflicts.mjs +70 -0
  40. package/src/control/can-use-guard.mjs +8 -3
  41. package/src/control/skill-crud.mjs +142 -0
  42. package/src/control/skill-variants.mjs +221 -0
  43. package/src/control/source-trust.mjs +1 -0
  44. package/src/control/variant-files.mjs +265 -0
  45. package/src/control/variant-lifecycle-config.mjs +156 -0
  46. package/src/control/variant-reset.mjs +171 -0
  47. package/src/control/variant-status.mjs +75 -0
  48. package/src/control.mjs +13 -1
  49. package/src/domain/rules/skills.mjs +60 -0
  50. package/src/domain/rules/workflows.mjs +13 -0
  51. package/src/impact.mjs +21 -12
  52. package/src/index.mjs +13 -1
  53. package/src/lifecycle-cli.mjs +18 -7
  54. package/src/lifecycle-content.mjs +29 -22
  55. package/src/route.mjs +537 -0
  56. package/src/source-verification.mjs +7 -3
  57. package/src/workspace.mjs +141 -43
  58. package/tsconfig.lsp.json +1 -1
package/src/route.mjs ADDED
@@ -0,0 +1,537 @@
1
+ import { command } from "./advisor/action-core.mjs";
2
+ import { canUseSkill } from "./control/can-use-guard.mjs";
3
+
4
+ const HIGH_CONFIDENCE = 4;
5
+ const MEDIUM_CONFIDENCE = 2;
6
+ const DEFAULT_CONFIG_PATH = "skillboard.config.yaml";
7
+ const DEFAULT_SKILLS_ROOT = "skills";
8
+ const ROUTE_STOP_WORDS = new Set([
9
+ "a",
10
+ "an",
11
+ "and",
12
+ "are",
13
+ "as",
14
+ "at",
15
+ "be",
16
+ "been",
17
+ "before",
18
+ "being",
19
+ "by",
20
+ "for",
21
+ "from",
22
+ "in",
23
+ "is",
24
+ "it",
25
+ "of",
26
+ "on",
27
+ "or",
28
+ "that",
29
+ "the",
30
+ "these",
31
+ "this",
32
+ "those",
33
+ "to",
34
+ "was",
35
+ "were",
36
+ "with",
37
+ "without",
38
+ "you",
39
+ "your"
40
+ ]);
41
+
42
+ export function routeSkill(workspace, options) {
43
+ const intent = options.intent.trim();
44
+ if (intent.length === 0) {
45
+ throw new Error("Usage: skillboard route <intent> --workflow <name>");
46
+ }
47
+ const workflow = workspace.workflows.find((candidate) => candidate.name === options.workflow);
48
+ if (workflow === undefined) {
49
+ throw new Error(`Unknown workflow: ${options.workflow}`);
50
+ }
51
+
52
+ const intentTokens = tokensFor(intent);
53
+ const candidates = capabilityRouteCandidates(workspace, workflow)
54
+ .map((candidate) => scoreCandidate(workspace, candidate, intent, intentTokens))
55
+ .sort((left, right) => right.score - left.score || left.capability.localeCompare(right.capability));
56
+ const best = candidates.find((candidate) => candidate.score > 0);
57
+ if (best === undefined) {
58
+ const skillCandidates = skillRouteCandidates(workspace, workflow, intent, intentTokens);
59
+ const skillBest = skillCandidates.find((candidate) => candidate.score > 0 && candidate.allowed)
60
+ ?? skillCandidates.find((candidate) => candidate.score > 0);
61
+ return skillBest === undefined
62
+ ? noRoute({ intent, workflow, candidates, skillCandidates, workspace })
63
+ : skillRoute({ intent, workflow, candidate: skillBest, candidates, skillCandidates, options, workspace });
64
+ }
65
+
66
+ const routedSkills = best.skill_ids.map((skillId, index) => ({
67
+ skill: skillId,
68
+ role: index === 0 ? "preferred" : "fallback",
69
+ guard: canUseSkill(workspace, skillId, workflow.name)
70
+ }));
71
+ const recommended = selectedRouteSkill(routedSkills);
72
+ const fallbackSkills = allowedFallbackSkills(routedSkills, recommended);
73
+ const postUsePolicySuggestion = postUsePolicySuggestionForCapabilityRoute({
74
+ matchedCapability: best.capability,
75
+ routeMatch: best,
76
+ recommended,
77
+ routedSkills,
78
+ workflowName: workflow.name,
79
+ options
80
+ });
81
+
82
+ return {
83
+ ok: true,
84
+ intent,
85
+ workflow: workflow.name,
86
+ matched_capability: best.capability,
87
+ matched_skill: null,
88
+ match_source: "capability",
89
+ confidence: confidenceFor(best.score),
90
+ matched_terms: best.matched_tokens,
91
+ recommendation_reason: recommendationReason({
92
+ match: `Matched capability ${best.capability}`,
93
+ matchedFields: best.matched_fields,
94
+ matchedTerms: best.matched_tokens,
95
+ skill: recommended?.skill ?? null,
96
+ guard: recommended?.guard ?? null
97
+ }),
98
+ recommended_skill: recommended?.skill ?? null,
99
+ fallback_skills: fallbackSkills,
100
+ route_candidates: routedSkills.map((entry) => routeCandidate(entry, entry.skill === recommended?.skill)),
101
+ guard_command: recommended === null ? null : guardCommand(recommended.skill, workflow.name, options),
102
+ guard: recommended?.guard ?? null,
103
+ usage_disclosure: recommended?.guard.allowed === true ? usageDisclosure(recommended.skill) : null,
104
+ post_use_policy_suggestion: postUsePolicySuggestion,
105
+ possible_skills: possibleWorkflowSkills(workspace, workflow),
106
+ candidates
107
+ };
108
+ }
109
+
110
+ function noRoute({ intent, workflow, candidates, skillCandidates, workspace }) {
111
+ return {
112
+ ok: true,
113
+ intent,
114
+ workflow: workflow.name,
115
+ matched_capability: null,
116
+ matched_skill: null,
117
+ match_source: "none",
118
+ confidence: "none",
119
+ matched_terms: [],
120
+ recommendation_reason: "No workflow capability or skill metadata matched this request. Ask a clarifying question before choosing a skill.",
121
+ recommended_skill: null,
122
+ fallback_skills: [],
123
+ route_candidates: [],
124
+ guard_command: null,
125
+ guard: null,
126
+ usage_disclosure: null,
127
+ post_use_policy_suggestion: null,
128
+ possible_skills: possibleWorkflowSkills(workspace, workflow),
129
+ candidates,
130
+ skill_candidates: skillCandidates
131
+ };
132
+ }
133
+
134
+ function skillRoute({ intent, workflow, candidate, candidates, skillCandidates, options, workspace }) {
135
+ const routeCandidates = skillCandidates
136
+ .filter((entry) => entry.score > 0)
137
+ .map((entry) => ({
138
+ skill: entry.skill_id,
139
+ role: entry.skill_id === candidate.skill_id ? "matched" : "alternative",
140
+ guard: canUseSkill(workspace, entry.skill_id, workflow.name)
141
+ }));
142
+ const selectedCandidate = routeCandidates.find((entry) => entry.skill === candidate.skill_id);
143
+ const guard = selectedCandidate?.guard ?? canUseSkill(workspace, candidate.skill_id, workflow.name);
144
+ const fallbackSkills = allowedFallbackSkills(routeCandidates, selectedCandidate);
145
+ return {
146
+ ok: true,
147
+ intent,
148
+ workflow: workflow.name,
149
+ matched_capability: null,
150
+ matched_skill: candidate.skill_id,
151
+ match_source: "skill-metadata",
152
+ confidence: confidenceFor(candidate.score),
153
+ matched_terms: candidate.matched_tokens,
154
+ recommendation_reason: recommendationReason({
155
+ match: `Matched workflow skill metadata for ${candidate.skill_id}`,
156
+ matchedFields: candidate.matched_fields,
157
+ matchedTerms: candidate.matched_tokens,
158
+ skill: candidate.skill_id,
159
+ guard
160
+ }),
161
+ recommended_skill: candidate.skill_id,
162
+ fallback_skills: fallbackSkills,
163
+ route_candidates: routeCandidates.map((entry) => routeCandidate(entry, entry.skill === candidate.skill_id)),
164
+ guard_command: guardCommand(candidate.skill_id, workflow.name, options),
165
+ guard,
166
+ usage_disclosure: guard.allowed ? usageDisclosure(candidate.skill_id) : null,
167
+ post_use_policy_suggestion: null,
168
+ possible_skills: possibleWorkflowSkills(workspace, workflow),
169
+ candidates,
170
+ skill_candidates: skillCandidates
171
+ };
172
+ }
173
+
174
+ function routeCandidate(entry, selected) {
175
+ return {
176
+ skill: entry.skill,
177
+ role: entry.role,
178
+ selected,
179
+ guard_allowed: entry.guard.allowed,
180
+ guard_reasons: entry.guard.reasons,
181
+ guard_roles: entry.guard.roles,
182
+ capability_roles: entry.guard.capabilityRoles
183
+ };
184
+ }
185
+
186
+ function usageDisclosure(skillId) {
187
+ return {
188
+ confirmation_required: false,
189
+ start: `State at the start that ${skillId} is being used for this request.`,
190
+ finish: `State at completion that ${skillId} was used.`,
191
+ start_message: `I will use ${skillId} for this request.`,
192
+ finish_message: `I used ${skillId} for this request.`,
193
+ guard: "Run the guard automatically immediately before invocation. Ask the user only if the guard denies use or a policy-changing action is needed."
194
+ };
195
+ }
196
+
197
+ function selectedRouteSkill(routedSkills) {
198
+ return routedSkills.find((entry) => entry.guard.allowed) ?? routedSkills[0] ?? null;
199
+ }
200
+
201
+ function allowedFallbackSkills(routedSkills, recommended) {
202
+ return routedSkills
203
+ .filter((entry) => entry.guard.allowed && entry.skill !== recommended?.skill)
204
+ .map((entry) => entry.skill);
205
+ }
206
+
207
+ function postUsePolicySuggestionForCapabilityRoute({ matchedCapability, routeMatch, recommended, routedSkills, workflowName, options }) {
208
+ return postUsePolicySuggestionForDeniedPreferredFallback({
209
+ matchedCapability,
210
+ recommended,
211
+ routedSkills,
212
+ workflowName,
213
+ options
214
+ }) ?? postUsePolicySuggestionForAllowedAmbiguity({
215
+ matchedCapability,
216
+ routeMatch,
217
+ recommended,
218
+ routedSkills,
219
+ workflowName,
220
+ options
221
+ });
222
+ }
223
+
224
+ function postUsePolicySuggestionForDeniedPreferredFallback({ matchedCapability, recommended, routedSkills, workflowName, options }) {
225
+ if (recommended === null || recommended.role !== "fallback" || recommended.guard.allowed !== true) {
226
+ return null;
227
+ }
228
+ const preferred = routedSkills.find((entry) => entry.role === "preferred");
229
+ if (preferred === undefined || preferred.guard.allowed === true) {
230
+ return null;
231
+ }
232
+ return {
233
+ timing: "after_use",
234
+ mode: "ask_after_use",
235
+ reason: `SkillBoard selected fallback ${recommended.skill} because preferred skill ${preferred.skill} is denied. After completing the task, ask whether to remember ${recommended.skill} as the preferred skill for ${matchedCapability} in ${workflowName}.`,
236
+ question: `Should I remember ${recommended.skill} as the preferred skill for similar ${matchedCapability} requests in ${workflowName}?`,
237
+ requires_confirmation: true,
238
+ suggested_policy: {
239
+ kind: "prefer-skill",
240
+ skill: recommended.skill,
241
+ workflow: workflowName,
242
+ capability: matchedCapability,
243
+ command_hint: command([
244
+ "skillboard", "prefer", recommended.skill,
245
+ "--workflow", workflowName,
246
+ "--capability", matchedCapability,
247
+ "--config", routeConfigPath(options),
248
+ "--skills", routeSkillsRoot(options)
249
+ ]).display
250
+ }
251
+ };
252
+ }
253
+
254
+ function postUsePolicySuggestionForAllowedAmbiguity({ matchedCapability, routeMatch, recommended, routedSkills, workflowName, options }) {
255
+ if (recommended === null || recommended.guard.allowed !== true || routeMatch.required_by_workflow) {
256
+ return null;
257
+ }
258
+ const allowedSkills = routedSkills.filter((entry) => entry.guard.allowed);
259
+ if (allowedSkills.length < 2) {
260
+ return null;
261
+ }
262
+ return {
263
+ timing: "after_use",
264
+ mode: "ask_after_use",
265
+ reason: `SkillBoard found multiple allowed skills for ${matchedCapability} and selected ${recommended.skill}. After completing the task, ask whether to remember ${recommended.skill} as the preferred skill for ${matchedCapability} in ${workflowName} to reduce future ambiguity.`,
266
+ question: `Should I remember ${recommended.skill} as the preferred skill for similar ${matchedCapability} requests in ${workflowName}?`,
267
+ requires_confirmation: true,
268
+ suggested_policy: {
269
+ kind: "prefer-skill",
270
+ skill: recommended.skill,
271
+ workflow: workflowName,
272
+ capability: matchedCapability,
273
+ command_hint: command([
274
+ "skillboard", "prefer", recommended.skill,
275
+ "--workflow", workflowName,
276
+ "--capability", matchedCapability,
277
+ "--config", routeConfigPath(options),
278
+ "--skills", routeSkillsRoot(options)
279
+ ]).display
280
+ }
281
+ };
282
+ }
283
+
284
+ function capabilityRouteCandidates(workspace, workflow) {
285
+ const workflowSkillIds = new Set(workflowBoundSkillIds(workspace, workflow));
286
+ return workspace.capabilities.flatMap((capability) => {
287
+ const requirement = workflow.requiredCapabilities.find((candidate) => candidate.name === capability.name);
288
+ const requiredSkillIds = uniqueStrings([
289
+ requirement?.preferred ?? "",
290
+ ...(requirement?.fallback ?? [])
291
+ ]);
292
+ const configuredSkillIds = uniqueStrings([
293
+ ...requiredSkillIds,
294
+ capability.canonical,
295
+ ...capability.alternatives
296
+ ]);
297
+ const boundSkillIds = configuredSkillIds.filter((skillId) => workflowSkillIds.has(skillId));
298
+ if (requirement === undefined && boundSkillIds.length === 0) {
299
+ return [];
300
+ }
301
+ const skillIds = requirement === undefined ? boundSkillIds : uniqueStrings([...requiredSkillIds, ...boundSkillIds]);
302
+ const skills = skillIds
303
+ .map((skillId) => workspace.skills.find((skill) => skill.id === skillId))
304
+ .filter((skill) => skill !== undefined);
305
+ return [{
306
+ capability: capability.name,
307
+ default_policy: capability.defaultPolicy,
308
+ skill_ids: skillIds,
309
+ skills,
310
+ required_by_workflow: requirement !== undefined
311
+ }];
312
+ });
313
+ }
314
+
315
+ function workflowBoundSkillIds(workspace, workflow) {
316
+ return uniqueStrings([
317
+ ...workflow.activeSkills,
318
+ ...workflow.requiredCapabilities.flatMap((capability) => [capability.preferred, ...capability.fallback]),
319
+ ...workspace.skills.filter((skill) => skill.invocation === "global-auto").map((skill) => skill.id)
320
+ ]);
321
+ }
322
+
323
+ function scoreCandidate(workspace, candidate, intent, intentTokens) {
324
+ const capabilityTokens = tokensFor(candidate.capability);
325
+ let score = phraseMatchScore(intent, candidate.capability);
326
+ const matched_tokens = [];
327
+ const matched_fields = [];
328
+ if (score > 0) {
329
+ matched_fields.push("capability name");
330
+ }
331
+ for (const token of capabilityTokens) {
332
+ if (intentTokens.has(token)) {
333
+ score += 2;
334
+ matched_tokens.push(token);
335
+ matched_fields.push("capability name");
336
+ }
337
+ }
338
+ const metadataMatch = scoreMetadataTerms(
339
+ candidate.skills.flatMap((skill) => skillMetadataTerms(workspace, skill.id, 1)),
340
+ intentTokens
341
+ );
342
+ score += metadataMatch.score;
343
+ matched_tokens.push(...metadataMatch.matchedTokens);
344
+ matched_fields.push(...metadataMatch.matchedFields);
345
+ return {
346
+ capability: candidate.capability,
347
+ confidence: confidenceFor(score),
348
+ score,
349
+ matched_tokens: uniqueStrings(matched_tokens).sort(),
350
+ matched_fields: uniqueStrings(matched_fields),
351
+ recommended_skill: candidate.skill_ids[0] ?? null,
352
+ fallback_skills: candidate.skill_ids.slice(1),
353
+ skill_ids: candidate.skill_ids,
354
+ required_by_workflow: candidate.required_by_workflow
355
+ };
356
+ }
357
+
358
+ function skillRouteCandidates(workspace, workflow, intent, intentTokens) {
359
+ return possibleWorkflowSkills(workspace, workflow)
360
+ .map((skill) => scoreSkillCandidate(workspace, skill, intent, intentTokens))
361
+ .sort((left, right) => right.score - left.score || left.skill_id.localeCompare(right.skill_id));
362
+ }
363
+
364
+ function scoreSkillCandidate(workspace, possibleSkill, intent, intentTokens) {
365
+ const skill = workspace.skills.find((candidate) => candidate.id === possibleSkill.id);
366
+ let score = phraseMatchScore(intent, possibleSkill.id);
367
+ const matched_fields = score > 0 ? ["skill id"] : [];
368
+ const metadataMatch = scoreMetadataTerms(skillMetadataTerms(workspace, possibleSkill.id, 2), intentTokens);
369
+ score += metadataMatch.score;
370
+ matched_fields.push(...metadataMatch.matchedFields);
371
+ const matched_tokens = metadataMatch.matchedTokens;
372
+ return {
373
+ skill_id: possibleSkill.id,
374
+ category: possibleSkill.category,
375
+ allowed: possibleSkill.allowed,
376
+ confidence: confidenceFor(score),
377
+ score,
378
+ matched_tokens: uniqueStrings(matched_tokens).sort(),
379
+ matched_fields: uniqueStrings(matched_fields)
380
+ };
381
+ }
382
+
383
+ function scoreMetadataTerms(terms, intentTokens) {
384
+ let score = 0;
385
+ const matchedTokens = [];
386
+ const matchedFields = [];
387
+ const seenTokens = new Set();
388
+ for (const term of terms) {
389
+ for (const token of tokensFor(term.value)) {
390
+ if (!intentTokens.has(token)) {
391
+ continue;
392
+ }
393
+ matchedFields.push(term.label);
394
+ const matchedToken = canonicalRouteToken(token);
395
+ if (seenTokens.has(matchedToken)) {
396
+ continue;
397
+ }
398
+ seenTokens.add(matchedToken);
399
+ score += term.weight;
400
+ matchedTokens.push(matchedToken);
401
+ }
402
+ }
403
+ return {
404
+ score,
405
+ matchedTokens,
406
+ matchedFields: uniqueStrings(matchedFields)
407
+ };
408
+ }
409
+
410
+ function skillMetadataTerms(workspace, skillId, weight) {
411
+ const skill = workspace.skills.find((candidate) => candidate.id === skillId);
412
+ const installed = installedSkillFor(workspace, skillId);
413
+ return [
414
+ { label: "skill id", value: skill?.id ?? skillId, weight },
415
+ { label: "skill path", value: skill?.path ?? installed?.path ?? "", weight },
416
+ { label: "category", value: skill?.category ?? "", weight },
417
+ { label: "SKILL.md name", value: installed?.name ?? "", weight },
418
+ { label: "SKILL.md description", value: installed?.description ?? "", weight }
419
+ ];
420
+ }
421
+
422
+ function installedSkillFor(workspace, skillId) {
423
+ const skill = workspace.skills.find((candidate) => candidate.id === skillId);
424
+ return workspace.installedSkills.find((installed) => installed.id === skillId || installed.path === skill?.path);
425
+ }
426
+
427
+ function recommendationReason({ match, matchedFields, matchedTerms, skill, guard }) {
428
+ const fieldText = matchedFields.length === 0
429
+ ? ""
430
+ : ` through ${matchedFields.join(", ")}`;
431
+ const termText = matchedTerms.length === 0
432
+ ? ""
433
+ : ` (${matchedTerms.join(", ")})`;
434
+ if (skill === null || guard === null) {
435
+ return `${match}${fieldText}${termText}, but no workflow-bound skill is available.`;
436
+ }
437
+ if (guard.allowed) {
438
+ return `${match}${fieldText}${termText}; recommended ${skill} because the guard allows ${skill}.`;
439
+ }
440
+ const reason = guard.reasons[0] ?? "the guard did not provide a reason";
441
+ return `${match}${fieldText}${termText}; recommended ${skill}, but the guard currently denies it: ${reason}`;
442
+ }
443
+
444
+ function phraseMatchScore(intent, capability) {
445
+ const normalizedIntent = phraseKey(intent);
446
+ const normalizedCapability = phraseKey(capability);
447
+ if (normalizedIntent.length === 0 || normalizedCapability.length === 0) {
448
+ return 0;
449
+ }
450
+ return normalizedIntent.includes(normalizedCapability) || normalizedCapability.includes(normalizedIntent) ? 4 : 0;
451
+ }
452
+
453
+ function possibleWorkflowSkills(workspace, workflow) {
454
+ const ids = workflowBoundSkillIds(workspace, workflow);
455
+ return ids.map((skillId) => {
456
+ const skill = workspace.skills.find((candidate) => candidate.id === skillId);
457
+ const guard = canUseSkill(workspace, skillId, workflow.name);
458
+ return {
459
+ id: skillId,
460
+ status: skill?.status ?? null,
461
+ invocation: skill?.invocation ?? null,
462
+ category: skill?.category ?? null,
463
+ roles: guard.roles,
464
+ capability_roles: guard.capabilityRoles,
465
+ allowed: guard.allowed
466
+ };
467
+ });
468
+ }
469
+
470
+ function guardCommand(skillId, workflowName, options) {
471
+ return command([
472
+ "skillboard", "guard", "use", skillId,
473
+ "--workflow", workflowName,
474
+ "--config", routeConfigPath(options),
475
+ "--skills", routeSkillsRoot(options)
476
+ ]).display;
477
+ }
478
+
479
+ function routeConfigPath(options) {
480
+ return options.configPath ?? DEFAULT_CONFIG_PATH;
481
+ }
482
+
483
+ function routeSkillsRoot(options) {
484
+ return options.skillsRoot ?? DEFAULT_SKILLS_ROOT;
485
+ }
486
+
487
+ function confidenceFor(score) {
488
+ if (score >= HIGH_CONFIDENCE) {
489
+ return "high";
490
+ }
491
+ if (score >= MEDIUM_CONFIDENCE) {
492
+ return "medium";
493
+ }
494
+ if (score > 0) {
495
+ return "low";
496
+ }
497
+ return "none";
498
+ }
499
+
500
+ function tokensFor(value) {
501
+ return new Set(String(value)
502
+ .toLowerCase()
503
+ .split(/[^a-z0-9]+/u)
504
+ .flatMap(tokenForms)
505
+ .filter(isRouteToken));
506
+ }
507
+
508
+ function tokenForms(token) {
509
+ const singular = singularRouteToken(token);
510
+ return singular === token ? [token] : [token, singular];
511
+ }
512
+
513
+ function canonicalRouteToken(token) {
514
+ return singularRouteToken(token);
515
+ }
516
+
517
+ function singularRouteToken(token) {
518
+ if (token.length > 4 && token.endsWith("ies")) {
519
+ return `${token.slice(0, -3)}y`;
520
+ }
521
+ if (token.length > 3 && token.endsWith("s") && !/(?:ss|us|is)$/u.test(token)) {
522
+ return token.slice(0, -1);
523
+ }
524
+ return token;
525
+ }
526
+
527
+ function isRouteToken(token) {
528
+ return token.length > 1 && !ROUTE_STOP_WORDS.has(token);
529
+ }
530
+
531
+ function phraseKey(value) {
532
+ return [...tokensFor(value)].join(" ");
533
+ }
534
+
535
+ function uniqueStrings(values) {
536
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
537
+ }
@@ -233,6 +233,12 @@ function uniquePaths(paths) {
233
233
  return [...new Set(paths)];
234
234
  }
235
235
 
236
+ export async function skillContentDigest(skillFilePath) {
237
+ const hash = createHash("sha256");
238
+ hash.update(await readFile(skillFilePath));
239
+ return `${DIGEST_PREFIX}${hash.digest("hex")}`;
240
+ }
241
+
236
242
  async function skillContentDigests(workspace, skillsRoot) {
237
243
  const digests = new Map();
238
244
  if (skillsRoot === undefined) {
@@ -241,9 +247,7 @@ async function skillContentDigests(workspace, skillsRoot) {
241
247
  for (const skill of workspace.skills) {
242
248
  const skillPath = join(skillsRoot, skill.path, "SKILL.md");
243
249
  try {
244
- const hash = createHash("sha256");
245
- hash.update(await readFile(skillPath));
246
- digests.set(skill.id, `${DIGEST_PREFIX}${hash.digest("hex")}`);
250
+ digests.set(skill.id, await skillContentDigest(skillPath));
247
251
  } catch {
248
252
  digests.set(skill.id, null);
249
253
  }