Files
claude-code/src/services/skillLearning/skillLifecycle.ts
2026-04-22 22:38:09 +08:00

497 lines
14 KiB
TypeScript

import {
mkdir,
readdir,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { clearSkillIndexCache } from '../skillSearch/localSearch.js'
import type { LearnedSkillDraft } from './types.js'
import { writeLearnedSkill } from './skillGenerator.js'
export type ExistingSkill = {
name: string
path: string
description: string
content: string
confidence?: number
status?: 'active' | 'superseded' | 'archived' | 'deleted'
referencedBy?: string[]
safeToDelete?: boolean
quality?: 'low' | 'medium' | 'high'
}
export type SkillLifecycleDecision =
| { type: 'create'; draft: LearnedSkillDraft; reason: string }
| { type: 'merge'; targetSkill: ExistingSkill; patch: string; reason: string }
| {
type: 'replace'
targetSkill: ExistingSkill
draft: LearnedSkillDraft
reason: string
hardDelete?: boolean
}
| { type: 'archive'; targetSkill: ExistingSkill; reason: string }
| {
type: 'delete'
targetSkill: ExistingSkill
reason: string
confirmed?: boolean
}
export type ReplacementManifest = {
oldSkill: string
oldPath: string
newSkill?: string
newPath?: string
action: 'archive' | 'delete'
reason: string
replacedAt: string
recoverable: boolean
}
export type SkillLifecycleOptions = {
allowHardDelete?: boolean
archiveRoot?: string
manifestRoot?: string
now?: Date
}
export type LearnedArtifactKind = 'skill' | 'command' | 'agent'
export type ArtifactDraft = {
name: string
description: string
content: string
}
export async function compareExistingArtifacts(
kind: LearnedArtifactKind,
draft: ArtifactDraft,
rootsOrSkills: string[] | ExistingSkill[],
): Promise<ExistingSkill[]> {
const existing =
rootsOrSkills.length > 0 && typeof rootsOrSkills[0] === 'string'
? await loadExistingArtifacts(kind, rootsOrSkills as string[])
: (rootsOrSkills as ExistingSkill[])
const draftTerms = terms(
`${draft.name} ${draft.description} ${draft.content}`,
)
return existing
.map(skill => ({
skill,
score: overlapScore(
draftTerms,
terms(`${skill.name} ${skill.description} ${skill.content}`),
),
}))
.filter(item => item.score >= 0.18)
.sort((a, b) => b.score - a.score)
.map(item => item.skill)
}
export async function compareExistingSkills(
draft: LearnedSkillDraft,
rootsOrSkills: string[] | ExistingSkill[],
): Promise<ExistingSkill[]> {
return compareExistingArtifacts('skill', draft, rootsOrSkills)
}
export async function loadExistingArtifacts(
kind: LearnedArtifactKind,
roots: string[],
): Promise<ExistingSkill[]> {
if (kind === 'skill') return loadExistingSkills(roots)
const results: ExistingSkill[] = []
for (const root of roots) {
if (!existsSync(root)) continue
await collectArtifactFiles(root, results)
}
return results
}
export function decideSkillLifecycle(
draft: LearnedSkillDraft,
existingSkills: ExistingSkill[],
options: Pick<SkillLifecycleOptions, 'allowHardDelete'> = {},
): SkillLifecycleDecision {
const deletable = existingSkills.find(skill => isSafeToHardDelete(skill))
if (options.allowHardDelete && deletable) {
return {
type: 'delete',
targetSkill: deletable,
reason:
'Existing skill is low quality, unreferenced, and safe to delete.',
confirmed: true,
}
}
const target = existingSkills[0]
if (!target) {
return {
type: 'create',
draft,
reason: 'No overlapping active skill found.',
}
}
const draftTerms = terms(
`${draft.name} ${draft.description} ${draft.content}`,
)
const existingTerms = terms(
`${target.name} ${target.description} ${target.content}`,
)
const score = overlapScore(draftTerms, existingTerms)
if (
score >= 0.72 &&
draft.confidence >= 0.75 &&
shouldReplaceSkill(draft, target)
) {
return {
type: 'replace',
targetSkill: target,
draft,
reason: `New learned skill has high overlap (${score.toFixed(2)}) and higher confidence.`,
}
}
if (score >= 0.35) {
return {
type: 'merge',
targetSkill: target,
patch: buildMergePatch(draft),
reason: `Existing skill overlaps with the learned pattern (${score.toFixed(2)}).`,
}
}
return { type: 'create', draft, reason: 'Overlap is too low to merge.' }
}
export async function applySkillLifecycleDecision(
decision: SkillLifecycleDecision,
options: SkillLifecycleOptions = {},
): Promise<{
activePath?: string
archivedPath?: string
deletedPath?: string
manifestPath?: string
tombstonePath?: string
}> {
switch (decision.type) {
case 'create': {
return { activePath: await writeLearnedSkill(decision.draft) }
}
case 'merge': {
if (!isSkillLearningGenerated(decision.targetSkill)) {
process.stderr.write(
`[skill-learning] skip user-authored skill: ${decision.targetSkill.path}\n`,
)
return {}
}
return {
activePath: await writeMergePatch(decision.targetSkill, decision.patch),
}
}
case 'replace': {
if (!isSkillLearningGenerated(decision.targetSkill)) {
process.stderr.write(
`[skill-learning] skip user-authored skill: ${decision.targetSkill.path}\n`,
)
return {}
}
// Archive/delete the superseded skill before the replacement is
// written so that any search-index refresh between the two steps can
// never observe both skills active simultaneously. `decision.draft
// .outputPath` is the exact path `writeLearnedSkill` will target.
const predictedNewPath = decision.draft.outputPath
if (decision.hardDelete) {
const { deletedPath, manifestPath, tombstonePath } = await deleteSkill(
decision.targetSkill,
decision.reason,
{
newSkill: decision.draft.name,
newPath: predictedNewPath,
},
{ ...options, allowHardDelete: true },
)
const activePath = await writeLearnedSkill(decision.draft)
return { activePath, deletedPath, manifestPath, tombstonePath }
}
const { archivedPath, manifestPath } = await archiveSkill(
decision.targetSkill,
decision.reason,
{
newSkill: decision.draft.name,
newPath: predictedNewPath,
},
options,
)
const activePath = await writeLearnedSkill(decision.draft)
return { activePath, archivedPath, manifestPath }
}
case 'archive':
return await archiveSkill(
decision.targetSkill,
decision.reason,
undefined,
options,
)
case 'delete':
return await deleteSkill(
decision.targetSkill,
decision.reason,
undefined,
{
...options,
allowHardDelete:
options.allowHardDelete && decision.confirmed !== false,
},
)
}
}
export async function loadExistingSkills(
roots: string[],
): Promise<ExistingSkill[]> {
const skills: ExistingSkill[] = []
for (const root of roots) {
if (!existsSync(root)) continue
await collectSkillFiles(root, skills)
}
return skills
}
export async function archiveSkill(
skill: ExistingSkill,
reason: string,
replacement?: { newSkill?: string; newPath?: string },
options: SkillLifecycleOptions = {},
): Promise<{ archivedPath: string; manifestPath: string }> {
const skillDir = dirname(skill.path)
const archiveRoot = options.archiveRoot ?? join(dirname(skillDir), '.archive')
const archivedPath = join(
archiveRoot,
`${basename(skillDir)}-${timestamp(options.now)}`,
)
await mkdir(archiveRoot, { recursive: true })
await rename(skillDir, archivedPath)
const manifestPath = await writeReplacementManifest(
options.manifestRoot ?? archivedPath,
{
oldSkill: skill.name,
oldPath: skill.path,
newSkill: replacement?.newSkill,
newPath: replacement?.newPath,
action: 'archive',
reason,
replacedAt: (options.now ?? new Date()).toISOString(),
recoverable: true,
},
)
clearSkillIndexCache()
return { archivedPath, manifestPath }
}
export async function deleteSkill(
skill: ExistingSkill,
reason: string,
replacement?: { newSkill?: string; newPath?: string },
options: SkillLifecycleOptions = {},
): Promise<{
deletedPath: string
manifestPath: string
tombstonePath: string
}> {
if (!options.allowHardDelete) {
throw new Error('Hard delete requires allowHardDelete=true')
}
const skillDir = dirname(skill.path)
const content = existsSync(skill.path)
? await readFile(skill.path, 'utf8')
: ''
const manifestRoot =
options.manifestRoot ?? join(dirname(skillDir), '.tombstones')
const manifestPath = await writeReplacementManifest(manifestRoot, {
oldSkill: skill.name,
oldPath: skill.path,
newSkill: replacement?.newSkill,
newPath: replacement?.newPath,
action: 'delete',
reason,
replacedAt: (options.now ?? new Date()).toISOString(),
recoverable: false,
})
const tombstonePath = join(
manifestRoot,
`${skill.name}-${timestamp(options.now)}.tombstone.json`,
)
await writeFile(
tombstonePath,
`${JSON.stringify({ deletedSkill: skill.name, oldPath: skill.path, content }, null, 2)}\n`,
'utf8',
)
await rm(skillDir, { recursive: true, force: true })
clearSkillIndexCache()
return { deletedPath: skill.path, manifestPath, tombstonePath }
}
export async function writeReplacementManifest(
directory: string,
manifest: ReplacementManifest,
): Promise<string> {
await mkdir(directory, { recursive: true })
const manifestPath = join(directory, 'replacement-manifest.json')
await writeFile(
manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
'utf8',
)
return manifestPath
}
async function writeMergePatch(
skill: ExistingSkill,
patch: string,
): Promise<string> {
const patchPath = join(dirname(skill.path), 'learned-skill.patch.md')
await writeFile(patchPath, patch, 'utf8')
clearSkillIndexCache()
return patchPath
}
function buildMergePatch(draft: LearnedSkillDraft): string {
return [
'# Learned Skill Merge Patch',
'',
`Target learned skill: ${draft.name}`,
`Confidence: ${draft.confidence}`,
'',
'## Suggested additions',
'',
draft.content,
].join('\n')
}
function shouldReplaceSkill(
draft: LearnedSkillDraft,
target: ExistingSkill,
): boolean {
if (target.status === 'superseded' || target.status === 'archived')
return true
const confidenceGap = draft.confidence - (target.confidence ?? 0.5)
const contentGap = draft.content.length - target.content.length
return confidenceGap >= 0.15 || contentGap > 160
}
function isSafeToHardDelete(skill: ExistingSkill): boolean {
return (
skill.safeToDelete === true &&
(skill.referencedBy?.length ?? 0) === 0 &&
skill.quality === 'low'
)
}
function timestamp(date = new Date()): string {
return date.toISOString().replace(/[:.]/g, '-')
}
async function collectSkillFiles(
root: string,
results: ExistingSkill[],
): Promise<void> {
const entries = await readdir(root, { withFileTypes: true })
for (const entry of entries) {
const full = join(root, entry.name)
if (entry.isDirectory()) {
if (entry.name === '.archive') continue
await collectSkillFiles(full, results)
continue
}
if (entry.isFile() && entry.name === 'SKILL.md') {
const content = await readFile(full, 'utf8')
results.push({
name: parseFrontmatter(content, 'name') ?? basename(dirname(full)),
description: parseFrontmatter(content, 'description') ?? '',
path: full,
content,
})
}
}
}
async function collectArtifactFiles(
root: string,
results: ExistingSkill[],
): Promise<void> {
const entries = await readdir(root, { withFileTypes: true })
for (const entry of entries) {
const full = join(root, entry.name)
if (entry.isDirectory()) {
if (entry.name === '.archive') continue
await collectArtifactFiles(full, results)
continue
}
if (entry.isFile() && entry.name.endsWith('.md')) {
const content = await readFile(full, 'utf8')
results.push({
name:
parseFrontmatter(content, 'name') ?? entry.name.replace(/\.md$/, ''),
description: parseFrontmatter(content, 'description') ?? '',
path: full,
content,
})
}
}
}
function parseFrontmatter(content: string, key: string): string | undefined {
// Restrict the search to the actual YAML frontmatter block between the
// opening `---` and the next `---`. A naked body line like
// `origin: skill-learning` in a user-authored doc must NOT be mistaken
// for a generated-skill marker.
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!fmMatch) return undefined
const match = fmMatch[1].match(new RegExp(`^${key}:\\s*"?([^"\\n]+)"?`, 'm'))
return match?.[1]?.trim()
}
function isSkillLearningGenerated(skill: ExistingSkill): boolean {
return parseFrontmatter(skill.content, 'origin') === 'skill-learning'
}
function terms(value: string): Set<string> {
return new Set(
value
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(term => term.length > 2),
)
}
function overlapScore(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const term of a) {
if (b.has(term)) intersection++
}
return intersection / Math.min(a.size, b.size)
}
export function scoreArtifactOverlap(
draft: ArtifactDraft,
existing: { name: string; description: string; content: string },
): number {
const draftTerms = terms(
`${draft.name} ${draft.description} ${draft.content}`,
)
const existingTerms = terms(
`${existing.name} ${existing.description} ${existing.content}`,
)
return overlapScore(draftTerms, existingTerms)
}