@erclx/aitk 3.15.0 → 3.17.0

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.
@@ -0,0 +1,78 @@
1
+ import type { SkillCase } from '@/claude/skills-rank'
2
+
3
+ /**
4
+ * `setup-*`, `migration-*`, `toolkit-*`, and `create-rule`: scaffolding,
5
+ * proposal-only migrations, and the toolkit's own reference and feedback
6
+ * surfaces.
7
+ */
8
+ export const SETUP_CASES: readonly SkillCase[] = [
9
+ {
10
+ prompt:
11
+ 'This project has no rules installed yet, get the right governance in place.',
12
+ expect: 'setup-gov',
13
+ },
14
+ {
15
+ prompt:
16
+ "Get the index.md system bootstrapped across this project's folders.",
17
+ expect: 'setup-indexes',
18
+ },
19
+ {
20
+ prompt:
21
+ 'This is a brand-new project, get the toolkit bootstrapped in one shot.',
22
+ expect: 'setup-init',
23
+ },
24
+ {
25
+ prompt: 'Get the usual Claude Code plugins provisioned on this machine.',
26
+ expect: 'setup-plugins',
27
+ },
28
+ {
29
+ prompt:
30
+ "Run through the generated scaffold's scripts and confirm each one passes.",
31
+ expect: 'setup-verify',
32
+ },
33
+ {
34
+ prompt:
35
+ 'This CLAUDE.md file has grown huge, break it apart into the tiered context model.',
36
+ expect: 'migration-claude-md',
37
+ },
38
+ {
39
+ prompt:
40
+ 'Move the agent-flavored docs out of the docs folder and into context.',
41
+ expect: 'migration-context',
42
+ },
43
+ {
44
+ prompt:
45
+ 'The snippets folder needs to move under .claude to match the current layout.',
46
+ expect: 'migration-standards',
47
+ },
48
+ {
49
+ prompt:
50
+ 'This file was replaced by a folder, help me split its content into it.',
51
+ expect: 'migration-superseded',
52
+ },
53
+ {
54
+ prompt:
55
+ "Before I run this sync, tell me exactly what it's going to overwrite.",
56
+ expect: 'toolkit-cli',
57
+ },
58
+ {
59
+ prompt:
60
+ 'Something about the toolkit itself is broken, write it up and send it back to the maintainers.',
61
+ expect: 'toolkit-feedback',
62
+ },
63
+ {
64
+ prompt:
65
+ "I don't know which specific toolkit skill I need, just handle it for me.",
66
+ expect: 'toolkit-operator',
67
+ },
68
+ {
69
+ prompt:
70
+ 'Work through the open feedback issues on the toolkit repo one by one.',
71
+ expect: 'toolkit-triage',
72
+ },
73
+ {
74
+ prompt:
75
+ "This project needs its own coding rule that the toolkit doesn't ship.",
76
+ expect: 'create-rule',
77
+ },
78
+ ]
@@ -0,0 +1,240 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { listSkills } from '@/claude/skills-list'
4
+
5
+ /**
6
+ * Whether a prompt reaches the right skill, measured by TF-IDF cosine
7
+ * similarity over the shipped catalog's own frontmatter descriptions. This is
8
+ * a necessary condition rather than a report of real routing behavior: it
9
+ * asks whether the descriptions are separable by the words they use, and
10
+ * Claude Code does not route this way.
11
+ *
12
+ * Ported from `.claude/groundwork/42-ai-blueprint/evidence/rank.ts`, which
13
+ * ran once against this catalog and named the collisions this measure now
14
+ * tracks on a cadence.
15
+ */
16
+
17
+ const SKILLS_DIR = join('claude', 'skills')
18
+
19
+ const STOP = new Set(
20
+ 'a about after again all also and any are as at be before but by can do does for from has have help how in into is it its just make not of on or our so that the then there these this to use used uses using want was what when where which who why with you your run'.split(
21
+ ' ',
22
+ ),
23
+ )
24
+
25
+ export interface RankedSkill {
26
+ readonly name: string
27
+ readonly description: string
28
+ }
29
+
30
+ /** One prompt and the skill it should reach. */
31
+ export interface SkillCase {
32
+ readonly prompt: string
33
+ readonly expect: string
34
+ }
35
+
36
+ /** A case whose prompt did not rank its expected skill first. */
37
+ export interface Miss {
38
+ readonly prompt: string
39
+ readonly expect: string
40
+ /** The skill the ranker placed first instead. */
41
+ readonly won: string
42
+ /** Where `expect` placed, or 0 when it never appears in the catalog. */
43
+ readonly rank: number
44
+ }
45
+
46
+ /** Why a measure produced no reading, which is never the same as a clean one. */
47
+ export type RankRefusal = 'no-skills'
48
+
49
+ export type RankReport =
50
+ | {
51
+ readonly kind: 'measured'
52
+ readonly skills: number
53
+ readonly cases: number
54
+ readonly rank1: number
55
+ readonly top3: number
56
+ readonly misses: readonly Miss[]
57
+ /** Cases whose prompt carried no vocabulary to score. */
58
+ readonly unmeasurable: readonly SkillCase[]
59
+ }
60
+ | { readonly kind: 'refused'; readonly reason: RankRefusal }
61
+
62
+ /**
63
+ * Every shipped skill's frontmatter description, read the way a prompt is
64
+ * matched against it: whole, including the quoted trigger phrases it states.
65
+ * A skill whose frontmatter carries no description contributes no vocabulary
66
+ * and never wins a rank, so it is dropped rather than scored on nothing.
67
+ */
68
+ export function loadCatalog(root: string): RankedSkill[] {
69
+ return listSkills(root)
70
+ .filter((skill) => skill.description !== '')
71
+ .map((skill) => ({ name: skill.name, description: skill.description }))
72
+ }
73
+
74
+ function tokenize(text: string): string[] {
75
+ return text
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9\s-]/g, ' ')
78
+ .split(/\s+/)
79
+ .filter((token) => token.length > 2 && !STOP.has(token))
80
+ }
81
+
82
+ function termCounts(tokens: readonly string[]): Map<string, number> {
83
+ const counts = new Map<string, number>()
84
+ for (const token of tokens) counts.set(token, (counts.get(token) ?? 0) + 1)
85
+ return counts
86
+ }
87
+
88
+ function buildIdf(
89
+ docs: readonly Map<string, number>[],
90
+ ): (term: string) => number {
91
+ const total = docs.length
92
+ const documentFrequency = new Map<string, number>()
93
+ for (const doc of docs) {
94
+ for (const term of doc.keys()) {
95
+ documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1)
96
+ }
97
+ }
98
+ return (term) =>
99
+ Math.log((total + 1) / ((documentFrequency.get(term) ?? 0) + 1)) + 1
100
+ }
101
+
102
+ function tfIdfVector(
103
+ terms: Map<string, number>,
104
+ idf: (term: string) => number,
105
+ ): Map<string, number> {
106
+ const vec = new Map<string, number>()
107
+ for (const [term, frequency] of terms) vec.set(term, frequency * idf(term))
108
+ return vec
109
+ }
110
+
111
+ function cosineSimilarity(
112
+ a: Map<string, number>,
113
+ b: Map<string, number>,
114
+ ): number {
115
+ let dot = 0
116
+ for (const [term, weight] of a) dot += weight * (b.get(term) ?? 0)
117
+ if (dot === 0) return 0
118
+
119
+ const norm = (vec: Map<string, number>) =>
120
+ Math.sqrt(
121
+ [...vec.values()].reduce((sum, weight) => sum + weight * weight, 0),
122
+ )
123
+ const denominator = norm(a) * norm(b)
124
+ return denominator === 0 ? 0 : dot / denominator
125
+ }
126
+
127
+ export interface RankedResult {
128
+ readonly name: string
129
+ readonly score: number
130
+ }
131
+
132
+ /**
133
+ * Every skill's TF-IDF vector over the catalog it was loaded with, and the
134
+ * IDF weighting that both the catalog and a scored prompt read from. A model
135
+ * is built once per catalog and reused across every case, since the IDF
136
+ * weights are the same question asked of the same corpus each time.
137
+ */
138
+ export interface RankModel {
139
+ readonly rank: (prompt: string) => readonly RankedResult[]
140
+ }
141
+
142
+ export function buildModel(catalog: readonly RankedSkill[]): RankModel {
143
+ const docs = catalog.map((skill) => termCounts(tokenize(skill.description)))
144
+ const idf = buildIdf(docs)
145
+ const vectors = new Map(
146
+ catalog.map((skill, index) => [skill.name, tfIdfVector(docs[index], idf)]),
147
+ )
148
+
149
+ return {
150
+ rank: (prompt: string): readonly RankedResult[] => {
151
+ const promptVector = tfIdfVector(termCounts(tokenize(prompt)), idf)
152
+ // A prompt built entirely from stopwords and short words tokenizes to
153
+ // nothing, so every skill would score 0 and the sort would fall through
154
+ // to `localeCompare`, handing the alphabetically first skill a win no
155
+ // description earned. Reporting no ranking at all is what keeps that
156
+ // tie-break from reading as a measurement.
157
+ if (promptVector.size === 0) return []
158
+
159
+ return [...vectors]
160
+ .map(([name, vector]) => ({
161
+ name,
162
+ score: cosineSimilarity(promptVector, vector),
163
+ }))
164
+ .sort(
165
+ (left, right) =>
166
+ right.score - left.score || left.name.localeCompare(right.name),
167
+ )
168
+ },
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Scores every case against the catalog's own descriptions, which is the
174
+ * production mechanism: Claude Code matches a prompt against the whole
175
+ * description a skill ships, triggers included.
176
+ */
177
+ export function measureCases(
178
+ catalog: readonly RankedSkill[],
179
+ cases: readonly SkillCase[],
180
+ ): {
181
+ readonly rank1: number
182
+ readonly top3: number
183
+ readonly misses: readonly Miss[]
184
+ readonly unmeasurable: readonly SkillCase[]
185
+ } {
186
+ const model = buildModel(catalog)
187
+ let rank1 = 0
188
+ let top3 = 0
189
+ const misses: Miss[] = []
190
+ const unmeasurable: SkillCase[] = []
191
+
192
+ for (const skillCase of cases) {
193
+ const ranked = model.rank(skillCase.prompt)
194
+ if (ranked.length === 0) {
195
+ unmeasurable.push(skillCase)
196
+ continue
197
+ }
198
+
199
+ const at = ranked.findIndex((entry) => entry.name === skillCase.expect) + 1
200
+
201
+ if (at === 1) rank1 += 1
202
+ if (at > 0 && at <= 3) top3 += 1
203
+ if (at !== 1) {
204
+ misses.push({
205
+ prompt: skillCase.prompt,
206
+ expect: skillCase.expect,
207
+ won: ranked[0]?.name ?? '',
208
+ rank: at,
209
+ })
210
+ }
211
+ }
212
+
213
+ return { rank1, top3, misses, unmeasurable }
214
+ }
215
+
216
+ /**
217
+ * Reads the shipped catalog off disk and scores it against the given case
218
+ * corpus. Measures the cwd's catalog rather than the toolkit root, matching
219
+ * the reach and audit verbs, so a linked worktree reads its own branch.
220
+ */
221
+ export function scanRank(
222
+ root: string,
223
+ cases: readonly SkillCase[],
224
+ ): RankReport {
225
+ const skillsRoot = join(root, SKILLS_DIR)
226
+ if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' }
227
+
228
+ const catalog = loadCatalog(root)
229
+ const { rank1, top3, misses, unmeasurable } = measureCases(catalog, cases)
230
+
231
+ return {
232
+ kind: 'measured',
233
+ skills: catalog.length,
234
+ cases: cases.length,
235
+ rank1,
236
+ top3,
237
+ misses,
238
+ unmeasurable,
239
+ }
240
+ }
@@ -34,6 +34,12 @@ import {
34
34
  type ReachReport,
35
35
  scanReach,
36
36
  } from '@/claude/skills-reach'
37
+ import { SKILL_CASES } from '@/claude/cases/all'
38
+ import {
39
+ type RankRefusal,
40
+ type RankReport,
41
+ scanRank,
42
+ } from '@/claude/skills-rank'
37
43
  import {
38
44
  planSettings,
39
45
  readSettings,
@@ -85,6 +91,10 @@ interface SkillsReachOptions {
85
91
  readonly json?: boolean
86
92
  }
87
93
 
94
+ interface SkillsRankOptions {
95
+ readonly json?: boolean
96
+ }
97
+
88
98
  interface RoutingOptions {
89
99
  readonly json?: boolean
90
100
  }
@@ -215,15 +225,18 @@ export function register(program: Command): void {
215
225
 
216
226
  const skills = claude
217
227
  .command('skills')
218
- .description('Plugin skill catalog (list, audit, drift, reach)')
219
- .argument('[subcommand]', "One of 'list', 'audit', 'drift', or 'reach'")
228
+ .description('Plugin skill catalog (list, audit, drift, reach, rank)')
229
+ .argument(
230
+ '[subcommand]',
231
+ "One of 'list', 'audit', 'drift', 'reach', or 'rank'",
232
+ )
220
233
  .helpOption('-h, --help', 'Show this help message')
221
234
  .action((subcommand: string | undefined) => {
222
235
  intro('aitk claude')
223
236
  logError(
224
237
  subcommand === undefined
225
- ? "Missing subcommand. Use 'list', 'audit', 'drift', or 'reach'."
226
- : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', 'drift', or 'reach'.`,
238
+ ? "Missing subcommand. Use 'list', 'audit', 'drift', 'reach', or 'rank'."
239
+ : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', 'drift', 'reach', or 'rank'.`,
227
240
  )
228
241
  outro()
229
242
  process.exitCode = 1
@@ -351,6 +364,42 @@ export function register(program: Command): void {
351
364
  .action((path: string | undefined, opts: SkillsReachOptions) => {
352
365
  process.exitCode = runSkillsReach(path, opts)
353
366
  })
367
+
368
+ skills
369
+ .command('rank')
370
+ .description('Score the shipped catalog against the routing case corpus')
371
+ .argument('[path]', 'Repository root, defaulting to the current directory')
372
+ .helpOption('-h, --help', 'Show this help message')
373
+ .option('--json', 'Add a machine-readable record on stdout')
374
+ .addHelpText(
375
+ 'after',
376
+ [
377
+ '',
378
+ 'Scope:',
379
+ ' TF-IDF cosine similarity over every claude/skills/*/SKILL.md',
380
+ ' frontmatter description, scored against the hand-authored corpus',
381
+ ' at src/claude/cases/. A necessary condition rather than a report of',
382
+ ' real routing behavior: it asks whether the descriptions are',
383
+ ' separable by the words they use, and Claude Code does not route',
384
+ ' this way.',
385
+ '',
386
+ 'Exit codes:',
387
+ ' 0 the catalog was read, whether or not a case missed rank one',
388
+ ' 1 refused, with the reason on stderr',
389
+ '',
390
+ 'Reports rather than gates. The corpus is a first run with no',
391
+ 'baseline to fail a push against, so `aitk audits run` registers',
392
+ 'this with no gating exit and joins the ratchet instead.',
393
+ '',
394
+ 'Examples:',
395
+ ' aitk claude skills rank',
396
+ ' aitk claude skills rank --json',
397
+ '',
398
+ ].join('\n'),
399
+ )
400
+ .action((path: string | undefined, opts: SkillsRankOptions) => {
401
+ process.exitCode = runSkillsRank(path, opts)
402
+ })
354
403
  }
355
404
 
356
405
  function succeed(message: string): number {
@@ -816,6 +865,101 @@ function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void {
816
865
  )
817
866
  }
818
867
 
868
+ /** What a reader does about the one way the measure fails to build. */
869
+ const RANK_REFUSALS: Record<RankRefusal, string> = {
870
+ 'no-skills':
871
+ 'No claude/skills/ here, so this tree ships no plugin body to measure.',
872
+ }
873
+
874
+ /**
875
+ * Measures the cwd rather than the toolkit root, matching the reach and audit
876
+ * verbs, so a linked worktree reads its own branch instead of `main`. The
877
+ * case corpus is the toolkit's own, since a target project ships no cases of
878
+ * its own for a catalog it did not author.
879
+ */
880
+ function runSkillsRank(
881
+ path: string | undefined,
882
+ opts: SkillsRankOptions,
883
+ ): number {
884
+ const root = resolve(path ?? process.cwd())
885
+ const report = scanRank(root, SKILL_CASES)
886
+
887
+ if (report.kind === 'refused') {
888
+ frameError(RANK_REFUSALS[report.reason])
889
+ if (opts.json) {
890
+ process.stdout.write(
891
+ `${JSON.stringify({
892
+ root,
893
+ reason: report.reason,
894
+ message: RANK_REFUSALS[report.reason],
895
+ })}\n`,
896
+ )
897
+ }
898
+ return 1
899
+ }
900
+
901
+ intro('aitk claude skills rank')
902
+ reportRank(report)
903
+ outro()
904
+
905
+ if (opts.json) {
906
+ process.stdout.write(
907
+ `${JSON.stringify({
908
+ root,
909
+ skills: report.skills,
910
+ cases: report.cases,
911
+ rank1: report.rank1,
912
+ top3: report.top3,
913
+ misses: report.misses,
914
+ unmeasurable: report.unmeasurable,
915
+ })}\n`,
916
+ )
917
+ }
918
+
919
+ return 0
920
+ }
921
+
922
+ /**
923
+ * States the corpus and both counts on every run, including a clean one. A
924
+ * miss list alone reads as a verdict on the catalog unless the run also says
925
+ * how many skills and cases it measured against.
926
+ */
927
+ function reportRank(report: Extract<RankReport, { kind: 'measured' }>): void {
928
+ logStep('Corpus')
929
+ logInfo(
930
+ `${plural(report.skills, 'skill')} scored against ${plural(report.cases, 'case')}`,
931
+ )
932
+
933
+ logStep('Score')
934
+ logInfo(
935
+ `rank one: ${report.rank1}/${report.cases}, top three: ${report.top3}/${report.cases}`,
936
+ )
937
+
938
+ if (report.unmeasurable.length > 0) {
939
+ logWarn(plural(report.unmeasurable.length, 'unmeasurable case'))
940
+ pipeOutput(
941
+ report.unmeasurable
942
+ .map((skillCase) => `${skillCase.expect} ${skillCase.prompt}`)
943
+ .join('\n'),
944
+ )
945
+ }
946
+
947
+ if (report.misses.length === 0) {
948
+ logInfo('Every measurable case ranked its expected skill first.')
949
+ return
950
+ }
951
+
952
+ logWarn(plural(report.misses.length, 'collision'))
953
+ pipeOutput(
954
+ report.misses
955
+ .map(
956
+ (miss) =>
957
+ `${miss.expect} lost to ${miss.won} (rank ${miss.rank}) ${miss.prompt}`,
958
+ )
959
+ .join('\n'),
960
+ )
961
+ }
962
+
819
963
  /**
820
964
  * States the bound on every run, including the run that names nothing. A report
821
965
  * listing only what moved reads as a verdict on what a session holds, and the