selftune 0.2.18 → 0.2.20

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 (77) hide show
  1. package/README.md +9 -4
  2. package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +60 -0
  3. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +1 -0
  4. package/apps/local-dashboard/dist/assets/vendor-table-BIiI3YhS.js +1 -0
  5. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +12 -0
  6. package/apps/local-dashboard/dist/index.html +5 -5
  7. package/cli/selftune/alpha-upload/stage-canonical.ts +7 -6
  8. package/cli/selftune/constants.ts +10 -0
  9. package/cli/selftune/contribute/contribute.ts +30 -2
  10. package/cli/selftune/contribution-config.ts +249 -0
  11. package/cli/selftune/contribution-relay.ts +177 -0
  12. package/cli/selftune/contribution-signals.ts +219 -0
  13. package/cli/selftune/contribution-staging.ts +147 -0
  14. package/cli/selftune/contributions.ts +532 -0
  15. package/cli/selftune/creator-contributions.ts +333 -0
  16. package/cli/selftune/dashboard-contract.ts +209 -1
  17. package/cli/selftune/dashboard-server.ts +45 -11
  18. package/cli/selftune/eval/family-overlap.ts +714 -0
  19. package/cli/selftune/eval/hooks-to-evals.ts +182 -28
  20. package/cli/selftune/eval/synthetic-evals.ts +298 -11
  21. package/cli/selftune/evolution/evidence.ts +5 -0
  22. package/cli/selftune/evolution/evolve-body.ts +62 -2
  23. package/cli/selftune/evolution/evolve.ts +58 -1
  24. package/cli/selftune/evolution/validate-body.ts +10 -0
  25. package/cli/selftune/evolution/validate-host-replay.ts +236 -0
  26. package/cli/selftune/evolution/validate-proposal.ts +10 -0
  27. package/cli/selftune/evolution/validate-routing.ts +112 -5
  28. package/cli/selftune/export.ts +2 -2
  29. package/cli/selftune/index.ts +41 -5
  30. package/cli/selftune/ingestors/codex-rollout.ts +31 -35
  31. package/cli/selftune/ingestors/codex-wrapper.ts +32 -24
  32. package/cli/selftune/localdb/db.ts +2 -2
  33. package/cli/selftune/localdb/direct-write.ts +8 -3
  34. package/cli/selftune/localdb/materialize.ts +7 -2
  35. package/cli/selftune/localdb/queries.ts +712 -31
  36. package/cli/selftune/localdb/schema.ts +30 -1
  37. package/cli/selftune/recover.ts +153 -0
  38. package/cli/selftune/repair/skill-usage.ts +363 -4
  39. package/cli/selftune/routes/actions.ts +35 -1
  40. package/cli/selftune/routes/analytics.ts +14 -0
  41. package/cli/selftune/routes/index.ts +1 -0
  42. package/cli/selftune/routes/overview.ts +112 -4
  43. package/cli/selftune/routes/skill-report.ts +575 -11
  44. package/cli/selftune/status.ts +81 -2
  45. package/cli/selftune/sync.ts +56 -2
  46. package/cli/selftune/trust-model.ts +66 -0
  47. package/cli/selftune/types.ts +103 -0
  48. package/cli/selftune/utils/skill-detection.ts +43 -0
  49. package/cli/selftune/utils/text-similarity.ts +73 -0
  50. package/cli/selftune/watchlist.ts +65 -0
  51. package/package.json +1 -1
  52. package/packages/ui/src/components/ActivityTimeline.tsx +165 -150
  53. package/packages/ui/src/components/EvidenceViewer.tsx +419 -145
  54. package/packages/ui/src/components/EvolutionTimeline.tsx +81 -29
  55. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +33 -16
  56. package/packages/ui/src/components/RecentActivityFeed.tsx +72 -41
  57. package/packages/ui/src/components/section-cards.tsx +12 -9
  58. package/packages/ui/src/primitives/card.tsx +1 -1
  59. package/packages/ui/src/types.ts +4 -0
  60. package/skill/SKILL.md +11 -1
  61. package/skill/Workflows/AlphaUpload.md +4 -0
  62. package/skill/Workflows/Composability.md +78 -0
  63. package/skill/Workflows/Contribute.md +6 -3
  64. package/skill/Workflows/Contributions.md +97 -0
  65. package/skill/Workflows/CreatorContributions.md +74 -0
  66. package/skill/Workflows/Dashboard.md +31 -0
  67. package/skill/Workflows/Evals.md +57 -8
  68. package/skill/Workflows/Evolve.md +23 -0
  69. package/skill/Workflows/Ingest.md +7 -0
  70. package/skill/Workflows/Initialize.md +20 -1
  71. package/skill/Workflows/Recover.md +84 -0
  72. package/skill/Workflows/RepairSkillUsage.md +12 -4
  73. package/skill/Workflows/Sync.md +18 -12
  74. package/apps/local-dashboard/dist/assets/index-BMIS6uUh.css +0 -2
  75. package/apps/local-dashboard/dist/assets/index-DOu3iLD9.js +0 -16
  76. package/apps/local-dashboard/dist/assets/vendor-table-pHbDxq36.js +0 -8
  77. package/apps/local-dashboard/dist/assets/vendor-ui-DIwlrGlb.js +0 -12
@@ -0,0 +1,714 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { parseArgs } from "node:util";
5
+
6
+ import { getDb } from "../localdb/db.js";
7
+ import { queryQueryLog, querySkillUsageRecords } from "../localdb/queries.js";
8
+ import type {
9
+ SkillFamilyColdStartPair,
10
+ SkillFamilyColdStartSuspicion,
11
+ QueryLogRecord,
12
+ SkillFamilyOverlapMember,
13
+ SkillFamilyOverlapPair,
14
+ SkillFamilyOverlapReport,
15
+ SkillFamilyRefactorProposal,
16
+ SkillUsageRecord,
17
+ } from "../types.js";
18
+ import { CLIError } from "../utils/cli-error.js";
19
+ import { parseFrontmatter } from "../utils/frontmatter.js";
20
+ import {
21
+ findInstalledSkillNames,
22
+ findInstalledSkillPath,
23
+ findRepositoryClaudeSkillDirs,
24
+ findRepositorySkillDirs,
25
+ } from "../utils/skill-discovery.js";
26
+ import {
27
+ buildStopwordSet,
28
+ extractWhenToUseLines,
29
+ jaccardSimilarity,
30
+ tokenizeText,
31
+ } from "../utils/text-similarity.js";
32
+ import { buildEvalSet } from "./hooks-to-evals.js";
33
+
34
+ const DEFAULT_MIN_OVERLAP = 0.3;
35
+ const DEFAULT_MIN_SHARED = 2;
36
+ const DEFAULT_MAX_SHARED = 10;
37
+ const DESCRIPTION_SIMILARITY_THRESHOLD = 0.18;
38
+ const WHEN_TO_USE_SIMILARITY_THRESHOLD = 0.18;
39
+ const CONFUSION_QUERY_LINE_OVERLAP_THRESHOLD = 0.12;
40
+ const COMMAND_AUGMENTED_HIGH_SIMILARITY_THRESHOLD = 0.22;
41
+ const LOW_SUSPICION_SIMILARITY_THRESHOLD = 0.28;
42
+ const SHARED_TERM_LIMIT = 6;
43
+ const STATIC_PAIR_LIMIT = 10;
44
+
45
+ const STOPWORDS = buildStopwordSet([
46
+ "between",
47
+ "by",
48
+ "can",
49
+ "change",
50
+ "content",
51
+ "decision",
52
+ "decisions",
53
+ "do",
54
+ "get",
55
+ "help",
56
+ "i",
57
+ "if",
58
+ "my",
59
+ "state",
60
+ "their",
61
+ "users",
62
+ "want",
63
+ "wants",
64
+ "you",
65
+ "your",
66
+ ]);
67
+
68
+ interface FamilyOverlapOptions {
69
+ familyPrefix?: string;
70
+ parentSkillName?: string;
71
+ minOverlapPct?: number;
72
+ minSharedQueries?: number;
73
+ maxSharedQueries?: number;
74
+ searchDirs?: string[];
75
+ }
76
+
77
+ interface InstalledSkillSurface {
78
+ skillName: string;
79
+ skillPath?: string;
80
+ descriptionTokens: Set<string>;
81
+ whenToUseTokens: Set<string>;
82
+ whenToUseLines: string[];
83
+ commandSurfaces: string[];
84
+ }
85
+
86
+ function getEvalSkillSearchDirs(): string[] {
87
+ const cwd = process.cwd();
88
+ const homeDir = process.env.HOME ?? "";
89
+ const codexHome = process.env.CODEX_HOME ?? `${homeDir}/.codex`;
90
+ return [
91
+ ...findRepositorySkillDirs(cwd),
92
+ ...findRepositoryClaudeSkillDirs(cwd),
93
+ `${homeDir}/.agents/skills`,
94
+ `${homeDir}/.claude/skills`,
95
+ `${codexHome}/skills`,
96
+ ];
97
+ }
98
+
99
+ function normalizeQuery(value: string): string {
100
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
101
+ }
102
+
103
+ function inferFamilyPrefix(skills: string[]): string | undefined {
104
+ if (skills.length < 2) return undefined;
105
+ const firstPrefixes = skills.map((skill) => {
106
+ const hyphen = skill.indexOf("-");
107
+ return hyphen === -1 ? skill : skill.slice(0, hyphen + 1);
108
+ });
109
+ const candidate = firstPrefixes[0];
110
+ return firstPrefixes.every((prefix) => prefix === candidate) ? candidate : undefined;
111
+ }
112
+
113
+ function inferParentSkillName(
114
+ skills: string[],
115
+ explicitParent?: string,
116
+ familyPrefix?: string,
117
+ ): string {
118
+ if (explicitParent?.trim()) return explicitParent.trim();
119
+ const inferredPrefix = familyPrefix ?? inferFamilyPrefix(skills) ?? "family";
120
+ return inferredPrefix.endsWith("-") ? inferredPrefix.slice(0, -1) : inferredPrefix;
121
+ }
122
+
123
+ function toWorkflowName(skillName: string, familyPrefix?: string): string {
124
+ const stripped =
125
+ familyPrefix && skillName.startsWith(familyPrefix)
126
+ ? skillName.slice(familyPrefix.length)
127
+ : skillName;
128
+ return stripped.trim() || "default";
129
+ }
130
+
131
+ function buildPositiveQuerySet(
132
+ skillName: string,
133
+ skillRecords: SkillUsageRecord[],
134
+ queryRecords: QueryLogRecord[],
135
+ ): Set<string> {
136
+ const evalEntries = buildEvalSet(
137
+ skillRecords,
138
+ queryRecords,
139
+ skillName,
140
+ Number.MAX_SAFE_INTEGER,
141
+ false,
142
+ 42,
143
+ false,
144
+ );
145
+ return new Set(
146
+ evalEntries
147
+ .filter((entry) => entry.should_trigger)
148
+ .map((entry) => normalizeQuery(entry.query))
149
+ .filter(Boolean),
150
+ );
151
+ }
152
+
153
+ function buildMember(
154
+ skillName: string,
155
+ positiveQueries: Set<string>,
156
+ searchDirs: string[],
157
+ ): SkillFamilyOverlapMember {
158
+ return {
159
+ skill_name: skillName,
160
+ skill_path: findInstalledSkillPath(skillName, searchDirs),
161
+ positive_query_count: positiveQueries.size,
162
+ };
163
+ }
164
+
165
+ function scoreConsolidationPressure(overlapPct: number): "low" | "medium" | "high" {
166
+ if (overlapPct >= 0.6) return "high";
167
+ if (overlapPct >= 0.4) return "medium";
168
+ return "low";
169
+ }
170
+
171
+ function sharedTerms(
172
+ leftDescription: Set<string>,
173
+ leftWhenToUse: Set<string>,
174
+ rightDescription: Set<string>,
175
+ rightWhenToUse: Set<string>,
176
+ ): string[] {
177
+ const shared = new Set<string>();
178
+ for (const token of leftDescription) {
179
+ if (rightDescription.has(token) || rightWhenToUse.has(token)) shared.add(token);
180
+ }
181
+ for (const token of leftWhenToUse) {
182
+ if (rightDescription.has(token) || rightWhenToUse.has(token)) shared.add(token);
183
+ }
184
+ return [...shared].sort((a, b) => a.localeCompare(b)).slice(0, SHARED_TERM_LIMIT);
185
+ }
186
+
187
+ function buildSyntheticSiblingConfusionQueries(
188
+ left: InstalledSkillSurface,
189
+ right: InstalledSkillSurface,
190
+ sharedCommandSurfaces: string[],
191
+ ): string[] {
192
+ const leftSurfaceTokens = new Set([...left.descriptionTokens, ...left.whenToUseTokens]);
193
+ const rightSurfaceTokens = new Set([...right.descriptionTokens, ...right.whenToUseTokens]);
194
+ const candidates = new Set<string>();
195
+
196
+ const maybeAdd = (line: string, sourceTokens: Set<string>, compareTokens: Set<string>) => {
197
+ const trimmed = line.trim();
198
+ if (!trimmed) return;
199
+ const lineTokens = tokenizeText(trimmed, STOPWORDS);
200
+ const overlap = jaccardSimilarity(lineTokens, compareTokens);
201
+ if (
202
+ overlap >= CONFUSION_QUERY_LINE_OVERLAP_THRESHOLD ||
203
+ jaccardSimilarity(sourceTokens, compareTokens) >= WHEN_TO_USE_SIMILARITY_THRESHOLD
204
+ ) {
205
+ candidates.add(trimmed);
206
+ }
207
+ };
208
+
209
+ for (const line of left.whenToUseLines) {
210
+ maybeAdd(line, leftSurfaceTokens, rightSurfaceTokens);
211
+ }
212
+ for (const line of right.whenToUseLines) {
213
+ maybeAdd(line, rightSurfaceTokens, leftSurfaceTokens);
214
+ }
215
+ for (const command of sharedCommandSurfaces) {
216
+ candidates.add(`${command} for a sibling-family request`);
217
+ }
218
+
219
+ return [...candidates].slice(0, 4);
220
+ }
221
+
222
+ function extractCommandSurfaces(body: string): string[] {
223
+ const matches = body.matchAll(/```[\w-]*\r?\n([\s\S]*?)```/g);
224
+ const commands = new Set<string>();
225
+ for (const match of matches) {
226
+ const block = match[1] ?? "";
227
+ for (const line of block.split("\n")) {
228
+ const trimmed = line.trim();
229
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(">")) continue;
230
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
231
+ if (tokens.length < 2 || tokens[1]?.startsWith("-")) continue;
232
+ commands.add(`${tokens[0]} ${tokens[1]}`);
233
+ }
234
+ }
235
+ return [...commands].sort((a, b) => a.localeCompare(b));
236
+ }
237
+
238
+ function loadInstalledSkillSurface(skillName: string, searchDirs: string[]): InstalledSkillSurface {
239
+ const skillPath = findInstalledSkillPath(skillName, searchDirs);
240
+ if (!skillPath) {
241
+ return {
242
+ skillName,
243
+ descriptionTokens: new Set<string>(),
244
+ whenToUseTokens: new Set<string>(),
245
+ whenToUseLines: [],
246
+ commandSurfaces: [],
247
+ };
248
+ }
249
+
250
+ try {
251
+ const raw = readFileSync(skillPath, "utf8");
252
+ const parsed = parseFrontmatter(raw);
253
+ const whenToUseLines = extractWhenToUseLines(parsed.body);
254
+ return {
255
+ skillName,
256
+ skillPath,
257
+ descriptionTokens: tokenizeText(parsed.description, STOPWORDS),
258
+ whenToUseTokens: tokenizeText(whenToUseLines.join(" "), STOPWORDS),
259
+ whenToUseLines,
260
+ commandSurfaces: extractCommandSurfaces(parsed.body),
261
+ };
262
+ } catch {
263
+ // Discovery is intentionally silent here: missing or malformed skill files are
264
+ // expected in mixed registries, and callers should degrade to empty surfaces.
265
+ return {
266
+ skillName,
267
+ skillPath,
268
+ descriptionTokens: new Set<string>(),
269
+ whenToUseTokens: new Set<string>(),
270
+ whenToUseLines: [],
271
+ commandSurfaces: [],
272
+ };
273
+ }
274
+ }
275
+
276
+ function scoreStaticSuspicion(
277
+ descriptionSimilarity: number,
278
+ whenToUseSimilarity: number,
279
+ sharedCommandSurfaces: string[],
280
+ ): "low" | "medium" | "high" | null {
281
+ const descriptionSignal = descriptionSimilarity >= DESCRIPTION_SIMILARITY_THRESHOLD;
282
+ const whenToUseSignal = whenToUseSimilarity >= WHEN_TO_USE_SIMILARITY_THRESHOLD;
283
+ const commandSignal = sharedCommandSurfaces.length > 0;
284
+ const signalCount = Number(descriptionSignal) + Number(whenToUseSignal) + Number(commandSignal);
285
+
286
+ if (
287
+ signalCount >= 3 ||
288
+ (commandSignal &&
289
+ Math.max(descriptionSimilarity, whenToUseSimilarity) >=
290
+ COMMAND_AUGMENTED_HIGH_SIMILARITY_THRESHOLD)
291
+ ) {
292
+ return "high";
293
+ }
294
+ if (signalCount >= 2) return "medium";
295
+ if (Math.max(descriptionSimilarity, whenToUseSimilarity) >= LOW_SUSPICION_SIMILARITY_THRESHOLD) {
296
+ return "low";
297
+ }
298
+ return null;
299
+ }
300
+
301
+ function analyzeColdStartSuspicion(
302
+ skills: string[],
303
+ searchDirs: string[],
304
+ readySkillCount: number,
305
+ ): SkillFamilyColdStartSuspicion | undefined {
306
+ const surfaces = skills.map((skillName) => loadInstalledSkillSurface(skillName, searchDirs));
307
+ const availableSurfaces = surfaces.filter(
308
+ (surface) =>
309
+ Boolean(surface.skillPath) &&
310
+ (surface.descriptionTokens.size > 0 ||
311
+ surface.whenToUseTokens.size > 0 ||
312
+ surface.commandSurfaces.length > 0),
313
+ );
314
+ if (availableSurfaces.length < 2) return undefined;
315
+
316
+ const pairs: SkillFamilyColdStartPair[] = [];
317
+ let analyzedPairs = 0;
318
+ for (let i = 0; i < availableSurfaces.length; i++) {
319
+ for (let j = i + 1; j < availableSurfaces.length; j++) {
320
+ analyzedPairs += 1;
321
+ const left = availableSurfaces[i];
322
+ const right = availableSurfaces[j];
323
+ if (!left || !right) continue;
324
+ const descriptionSimilarity = jaccardSimilarity(
325
+ left.descriptionTokens,
326
+ right.descriptionTokens,
327
+ );
328
+ const whenToUseSimilarity = jaccardSimilarity(left.whenToUseTokens, right.whenToUseTokens);
329
+ const sharedCommandSurfaces = left.commandSurfaces.filter((command) =>
330
+ right.commandSurfaces.includes(command),
331
+ );
332
+ const suspicionLevel = scoreStaticSuspicion(
333
+ descriptionSimilarity,
334
+ whenToUseSimilarity,
335
+ sharedCommandSurfaces,
336
+ );
337
+ if (!suspicionLevel) continue;
338
+
339
+ pairs.push({
340
+ skill_a: left.skillName,
341
+ skill_b: right.skillName,
342
+ description_similarity: descriptionSimilarity,
343
+ when_to_use_similarity: whenToUseSimilarity,
344
+ shared_command_surfaces: sharedCommandSurfaces,
345
+ shared_terms: sharedTerms(
346
+ left.descriptionTokens,
347
+ left.whenToUseTokens,
348
+ right.descriptionTokens,
349
+ right.whenToUseTokens,
350
+ ),
351
+ synthetic_confusion_queries: buildSyntheticSiblingConfusionQueries(
352
+ left,
353
+ right,
354
+ sharedCommandSurfaces,
355
+ ),
356
+ suspicion_level: suspicionLevel,
357
+ });
358
+ }
359
+ }
360
+
361
+ pairs.sort(
362
+ (a, b) =>
363
+ Number(b.suspicion_level === "high") - Number(a.suspicion_level === "high") ||
364
+ Number(b.suspicion_level === "medium") - Number(a.suspicion_level === "medium") ||
365
+ b.when_to_use_similarity - a.when_to_use_similarity ||
366
+ b.description_similarity - a.description_similarity,
367
+ );
368
+
369
+ const suspiciousPairCount = pairs.length;
370
+ const averageStaticSimilarity =
371
+ suspiciousPairCount > 0
372
+ ? pairs.reduce(
373
+ // Command overlap is binary, but we weight it equally with the two Jaccard scores
374
+ // because shared command surfaces are a high-precision cold-start signal even when
375
+ // description text is sparse or noisy.
376
+ (sum, pair) =>
377
+ sum +
378
+ (pair.description_similarity +
379
+ pair.when_to_use_similarity +
380
+ (pair.shared_command_surfaces.length > 0 ? 1 : 0)) /
381
+ 3,
382
+ 0,
383
+ ) / suspiciousPairCount
384
+ : 0;
385
+ const candidate =
386
+ suspiciousPairCount > 0 &&
387
+ readySkillCount < 2 &&
388
+ suspiciousPairCount >= (skills.length >= 3 ? 2 : 1);
389
+
390
+ const rationale: string[] = [];
391
+ if (suspiciousPairCount === 0) {
392
+ rationale.push(
393
+ "Installed skill surfaces do not show meaningful overlap yet. Keep gathering cold-start evals and real usage before making a packaging call.",
394
+ );
395
+ } else {
396
+ rationale.push(
397
+ `${suspiciousPairCount} sibling pair${suspiciousPairCount === 1 ? "" : "s"} show overlapping installed skill surfaces before trusted telemetry is available.`,
398
+ );
399
+ if (pairs.some((pair) => pair.shared_command_surfaces.length > 0)) {
400
+ rationale.push(
401
+ "Shared command surfaces suggest some siblings may be thin wrappers around the same backend or query path.",
402
+ );
403
+ }
404
+ if (pairs.some((pair) => pair.when_to_use_similarity >= WHEN_TO_USE_SIMILARITY_THRESHOLD)) {
405
+ rationale.push(
406
+ "Overlapping `When to Use` language suggests sibling boundaries may already be competing on intent before enough telemetry exists to confirm it.",
407
+ );
408
+ }
409
+ if (pairs.some((pair) => pair.synthetic_confusion_queries.length > 0)) {
410
+ rationale.push(
411
+ "Synthetic sibling-confusion probes are available for suspicious pairs, so you can test the family boundary before real telemetry converges.",
412
+ );
413
+ }
414
+ if (candidate) {
415
+ rationale.push(
416
+ "Treat this as architecture suspicion, not proof. Run cold-start evals and gather trusted usage before consolidating the family.",
417
+ );
418
+ }
419
+ }
420
+
421
+ return {
422
+ candidate,
423
+ analyzed_pairs: analyzedPairs,
424
+ suspicious_pair_count: suspiciousPairCount,
425
+ average_static_similarity: averageStaticSimilarity,
426
+ pairs: pairs.slice(0, STATIC_PAIR_LIMIT),
427
+ rationale,
428
+ };
429
+ }
430
+
431
+ function buildRefactorProposal(
432
+ skills: string[],
433
+ familyPrefix: string | undefined,
434
+ parentSkillName: string,
435
+ ): SkillFamilyRefactorProposal {
436
+ const workflows = skills.map((skillName) => {
437
+ const workflowName = toWorkflowName(skillName, familyPrefix);
438
+ return {
439
+ workflow_name: workflowName,
440
+ source_skill: skillName,
441
+ suggested_path: `Workflows/${workflowName}.md`,
442
+ };
443
+ });
444
+
445
+ return {
446
+ parent_skill_name: parentSkillName,
447
+ family_prefix: familyPrefix,
448
+ internal_workflows: workflows,
449
+ compatibility_aliases: workflows.map((workflow) => ({
450
+ skill_name: workflow.source_skill,
451
+ target_workflow: workflow.workflow_name,
452
+ })),
453
+ migration_notes: [
454
+ `Create a parent skill \`${parentSkillName}\` whose SKILL.md routes into internal workflows instead of exposing each family member as a primary top-level trigger surface.`,
455
+ "Keep the existing sibling skills as thin compatibility aliases for at least one release cycle while usage shifts to the parent skill.",
456
+ "Move execution-specific instructions into internal Workflows/ or references/ files so the parent SKILL.md stays focused on routing and progressive disclosure.",
457
+ "Use the compatibility aliases to measure whether trigger quality improves before removing the old skill entry points.",
458
+ ],
459
+ };
460
+ }
461
+
462
+ export function analyzeSkillFamilyOverlap(
463
+ skills: string[],
464
+ skillRecords: SkillUsageRecord[],
465
+ queryRecords: QueryLogRecord[],
466
+ options: FamilyOverlapOptions = {},
467
+ ): SkillFamilyOverlapReport {
468
+ if (skills.length < 2) {
469
+ throw new CLIError(
470
+ "Skill family overlap analysis requires at least 2 skills.",
471
+ "INVALID_FLAG",
472
+ "selftune eval family-overlap --skills skill-a,skill-b",
473
+ );
474
+ }
475
+
476
+ const searchDirs = options.searchDirs ?? getEvalSkillSearchDirs();
477
+ const familyPrefix = options.familyPrefix ?? inferFamilyPrefix(skills);
478
+ const minOverlapPct = options.minOverlapPct ?? DEFAULT_MIN_OVERLAP;
479
+ const minSharedQueries = options.minSharedQueries ?? DEFAULT_MIN_SHARED;
480
+ const maxSharedQueries = options.maxSharedQueries ?? DEFAULT_MAX_SHARED;
481
+
482
+ const positiveQueriesBySkill = new Map<string, Set<string>>();
483
+ const members: SkillFamilyOverlapMember[] = [];
484
+ for (const skillName of skills) {
485
+ const positives = buildPositiveQuerySet(skillName, skillRecords, queryRecords);
486
+ positiveQueriesBySkill.set(skillName, positives);
487
+ members.push(buildMember(skillName, positives, searchDirs));
488
+ }
489
+
490
+ const pairs: SkillFamilyOverlapPair[] = [];
491
+ for (let i = 0; i < skills.length; i++) {
492
+ for (let j = i + 1; j < skills.length; j++) {
493
+ const skillA = skills[i];
494
+ const skillB = skills[j];
495
+ const positivesA = positiveQueriesBySkill.get(skillA) ?? new Set<string>();
496
+ const positivesB = positiveQueriesBySkill.get(skillB) ?? new Set<string>();
497
+ if (positivesA.size === 0 || positivesB.size === 0) continue;
498
+
499
+ const sharedQueries = [...positivesA].filter((query) => positivesB.has(query));
500
+ const overlapPct = sharedQueries.length / Math.min(positivesA.size, positivesB.size);
501
+ if (sharedQueries.length < minSharedQueries || overlapPct < minOverlapPct) continue;
502
+
503
+ pairs.push({
504
+ skill_a: skillA,
505
+ skill_b: skillB,
506
+ overlap_pct: overlapPct,
507
+ shared_query_count: sharedQueries.length,
508
+ shared_queries: sharedQueries.slice(0, maxSharedQueries),
509
+ consolidation_pressure: scoreConsolidationPressure(overlapPct),
510
+ });
511
+ }
512
+ }
513
+
514
+ pairs.sort(
515
+ (a, b) => b.overlap_pct - a.overlap_pct || b.shared_query_count - a.shared_query_count,
516
+ );
517
+
518
+ const totalPairsAnalyzed = (skills.length * (skills.length - 1)) / 2;
519
+ const overlapCount = pairs.length;
520
+ const overlapDensity = totalPairsAnalyzed > 0 ? overlapCount / totalPairsAnalyzed : 0;
521
+ const averageOverlapPct =
522
+ overlapCount > 0 ? pairs.reduce((sum, pair) => sum + pair.overlap_pct, 0) / overlapCount : 0;
523
+ const readySkillCount = members.filter(
524
+ (member) => member.positive_query_count >= minSharedQueries,
525
+ ).length;
526
+ const coldStartSuspicion = analyzeColdStartSuspicion(skills, searchDirs, readySkillCount);
527
+ const consolidationCandidate =
528
+ readySkillCount >= 2 &&
529
+ skills.length >= 3 &&
530
+ (overlapCount >= 2 || (overlapCount >= 1 && overlapDensity >= 0.5));
531
+
532
+ const parentSkillName = inferParentSkillName(skills, options.parentSkillName, familyPrefix);
533
+ const rationale = [
534
+ `${skills.length} sibling skills analyzed with ${totalPairsAnalyzed} pairwise boundary checks.`,
535
+ overlapCount === 0
536
+ ? "No exact-query overlap crossed the current consolidation threshold."
537
+ : `${overlapCount} skill pairs share at least ${Math.round(minOverlapPct * 100)}% of their trusted positive queries.`,
538
+ ];
539
+
540
+ if (pairs.some((pair) => pair.consolidation_pressure === "high")) {
541
+ rationale.push(
542
+ "High-overlap pairs suggest the current top-level routing surfaces are competing for the same real user intent.",
543
+ );
544
+ }
545
+
546
+ if (readySkillCount < 2) {
547
+ rationale.push(
548
+ `Only ${readySkillCount} sibling skills currently have enough trusted positives to make a packaging call. Generate cold-start evals and gather real usage before treating this as evidence against consolidation.`,
549
+ );
550
+ }
551
+
552
+ if (readySkillCount < 2 && coldStartSuspicion?.candidate) {
553
+ rationale.push(
554
+ "Installed skill surfaces already suggest an architecture suspicion: some siblings look like overlapping entry points to the same underlying workflow family.",
555
+ );
556
+ }
557
+
558
+ if (consolidationCandidate) {
559
+ rationale.push(
560
+ "This family looks like a packaging problem, not just a wording problem. Test a parent skill with internal workflows before continuing standalone description optimization.",
561
+ );
562
+ }
563
+
564
+ return {
565
+ family_prefix: familyPrefix,
566
+ analyzed_skills: skills,
567
+ members,
568
+ pairs,
569
+ cold_start_suspicion: coldStartSuspicion,
570
+ total_pairs_analyzed: totalPairsAnalyzed,
571
+ overlap_count: overlapCount,
572
+ overlap_density: overlapDensity,
573
+ average_overlap_pct: averageOverlapPct,
574
+ consolidation_candidate: consolidationCandidate,
575
+ recommendation:
576
+ readySkillCount < 2
577
+ ? coldStartSuspicion?.candidate
578
+ ? "Trusted telemetry is still sparse, but installed skill surfaces suggest this family may want a parent skill. Treat this as cold-start architecture suspicion, then confirm with cold-start evals plus real usage."
579
+ : "Insufficient trusted telemetry to make a family-packaging call yet. Use cold-start evals plus a few days of real usage before deciding whether to consolidate."
580
+ : consolidationCandidate
581
+ ? `Consider consolidating this family under a parent skill like \`${parentSkillName}\`.`
582
+ : "Keep the skills separate for now and continue improving boundaries at the description/workflow level.",
583
+ rationale,
584
+ refactor_proposal: consolidationCandidate
585
+ ? buildRefactorProposal(skills, familyPrefix, parentSkillName)
586
+ : undefined,
587
+ generated_at: new Date().toISOString(),
588
+ };
589
+ }
590
+
591
+ function parseSkillList(raw: string | undefined): string[] {
592
+ if (!raw) return [];
593
+ return raw
594
+ .split(",")
595
+ .map((value) => value.trim())
596
+ .filter(Boolean);
597
+ }
598
+
599
+ function resolveFamilySkills(
600
+ explicitSkills: string[],
601
+ familyPrefix: string | undefined,
602
+ skillRecords: SkillUsageRecord[],
603
+ searchDirs: string[],
604
+ ): string[] {
605
+ if (explicitSkills.length > 0)
606
+ return [...new Set(explicitSkills)].sort((a, b) => a.localeCompare(b));
607
+
608
+ if (!familyPrefix) {
609
+ throw new CLIError(
610
+ "Pass either --skills <a,b,c> or --prefix <family->.",
611
+ "MISSING_FLAG",
612
+ "selftune eval family-overlap --prefix sc-",
613
+ );
614
+ }
615
+
616
+ const installedNames = findInstalledSkillNames(searchDirs);
617
+ const observedNames = new Set<string>(
618
+ skillRecords.map((record) => record.skill_name).filter(Boolean),
619
+ );
620
+ const familySkills = new Set<string>();
621
+ for (const name of [...installedNames, ...observedNames]) {
622
+ if (name.startsWith(familyPrefix)) familySkills.add(name);
623
+ }
624
+
625
+ return [...familySkills].sort((a, b) => a.localeCompare(b));
626
+ }
627
+
628
+ export async function cliMain(): Promise<void> {
629
+ let values: ReturnType<typeof parseArgs>["values"];
630
+ try {
631
+ ({ values } = parseArgs({
632
+ options: {
633
+ help: { type: "boolean", short: "h", default: false },
634
+ prefix: { type: "string" },
635
+ skills: { type: "string" },
636
+ "parent-skill": { type: "string" },
637
+ "min-overlap": { type: "string" },
638
+ "min-shared": { type: "string" },
639
+ },
640
+ strict: true,
641
+ }));
642
+ } catch (error) {
643
+ const message = error instanceof Error ? error.message : String(error);
644
+ throw new CLIError(
645
+ `Invalid arguments: ${message}`,
646
+ "INVALID_FLAG",
647
+ "selftune eval family-overlap --help",
648
+ );
649
+ }
650
+
651
+ if (values.help) {
652
+ console.log(`Usage:
653
+ selftune eval family-overlap --skills skill-a,skill-b[,skill-c]
654
+ selftune eval family-overlap --prefix sc-
655
+
656
+ Options:
657
+ --skills <a,b,c> Explicit skill names
658
+ --prefix <family-> Analyze installed or observed skills with this prefix
659
+ --parent-skill <name> Override the inferred parent skill name
660
+ --min-overlap <0-1> Minimum overlap percentage (default: 0.3)
661
+ --min-shared <n> Minimum shared queries (default: 2)
662
+ -h, --help Show this help
663
+ `);
664
+ return;
665
+ }
666
+
667
+ const rawMinOverlap = values["min-overlap"] as string | undefined;
668
+ const rawMinShared = values["min-shared"] as string | undefined;
669
+ const minOverlapPct =
670
+ rawMinOverlap === undefined ? DEFAULT_MIN_OVERLAP : Number.parseFloat(rawMinOverlap);
671
+ const minSharedQueries =
672
+ rawMinShared === undefined ? DEFAULT_MIN_SHARED : Number.parseInt(rawMinShared, 10);
673
+
674
+ if (!Number.isFinite(minOverlapPct) || minOverlapPct <= 0 || minOverlapPct > 1) {
675
+ throw new CLIError(
676
+ "Invalid --min-overlap value. Use a number between 0 and 1.",
677
+ "INVALID_FLAG",
678
+ "selftune eval family-overlap --prefix sc- --min-overlap 0.3",
679
+ );
680
+ }
681
+
682
+ if (!Number.isFinite(minSharedQueries) || minSharedQueries < 1) {
683
+ throw new CLIError(
684
+ "Invalid --min-shared value. Use a positive integer.",
685
+ "INVALID_FLAG",
686
+ "selftune eval family-overlap --prefix sc- --min-shared 2",
687
+ );
688
+ }
689
+
690
+ const searchDirs = getEvalSkillSearchDirs();
691
+ const db = getDb();
692
+ const skillRecords = querySkillUsageRecords(db) as SkillUsageRecord[];
693
+ const queryRecords = queryQueryLog(db) as QueryLogRecord[];
694
+ const familyPrefix = (values.prefix as string | undefined)?.trim() || undefined;
695
+ const explicitSkills = parseSkillList(values.skills as string | undefined);
696
+ const skills = resolveFamilySkills(explicitSkills, familyPrefix, skillRecords, searchDirs);
697
+
698
+ if (skills.length < 2) {
699
+ throw new CLIError(
700
+ `Need at least 2 skills to analyze, found ${skills.length}.`,
701
+ "INVALID_FLAG",
702
+ "selftune eval family-overlap --prefix sc-",
703
+ );
704
+ }
705
+
706
+ const report = analyzeSkillFamilyOverlap(skills, skillRecords, queryRecords, {
707
+ familyPrefix,
708
+ parentSkillName: (values["parent-skill"] as string | undefined)?.trim() || undefined,
709
+ minOverlapPct,
710
+ minSharedQueries,
711
+ searchDirs,
712
+ });
713
+ console.log(JSON.stringify(report, null, 2));
714
+ }