From 75fa2127592b50e820754e046bb6f7676d97cbea Mon Sep 17 00:00:00 2001 From: James Feng <47167674+GhostDragon124@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:27:08 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20remove=20skillLearning=20subsystem=20?= =?UTF-8?q?=E2=80=94=20unused,=20incompatible=20with=20CCP=20telemetry=20a?= =?UTF-8?q?pproach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/skillLearning/featureCheck.ts | 36 -- src/services/skillLearning/instinctParser.ts | 16 - src/services/skillLearning/projectContext.ts | 14 - src/services/skillLearning/skillGapStore.ts | 499 ------------------- src/services/skillLearning/skillGenerator.ts | 11 - src/services/skillLearning/types.ts | 7 - src/services/skillSearch/prefetch.ts | 56 +-- vite.config.ts | 1 - 8 files changed, 10 insertions(+), 630 deletions(-) delete mode 100644 src/services/skillLearning/featureCheck.ts delete mode 100644 src/services/skillLearning/instinctParser.ts delete mode 100644 src/services/skillLearning/projectContext.ts delete mode 100644 src/services/skillLearning/skillGapStore.ts delete mode 100644 src/services/skillLearning/skillGenerator.ts delete mode 100644 src/services/skillLearning/types.ts diff --git a/src/services/skillLearning/featureCheck.ts b/src/services/skillLearning/featureCheck.ts deleted file mode 100644 index 9a1488ee1..000000000 --- a/src/services/skillLearning/featureCheck.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { feature } from 'bun:bundle' - -/** - * Build-time presence check: is the `/skill-learning` slash command - * compiled into this build? Used by the command registry's `isEnabled` so - * the command appears in the menu whenever it is buildable. Operators - * activate the subsystem itself via `/skill-learning start`, which flips - * `SKILL_LEARNING_ENABLED=1` and turns the runtime observers on (see - * `isSkillLearningEnabled`). - */ -export function isSkillLearningCompiledIn(): boolean { - if (feature('SKILL_LEARNING')) return true - return false -} - -/** - * Runtime activation check: is the skill-learning subsystem actively - * running (toolEvent, runtime, session observers attached, persisting - * observations to disk)? Off by default — the operator must run - * `/skill-learning start` (which sets `SKILL_LEARNING_ENABLED=1`). - * - * Legacy `FEATURE_SKILL_LEARNING=1` is also accepted for backward - * compatibility with operators who set it before the slash-command UX - * landed. - * - * Build-flag gating is intentionally NOT performed here: the command - * registry already gates command compilation on the build flag, and this - * function is only reached from code paths that the build flag has - * already let through. Decoupling keeps the test surface clean (tests - * exercise the env-var contract without needing to mock `bun:bundle`). - */ -export function isSkillLearningEnabled(): boolean { - if (process.env.SKILL_LEARNING_ENABLED === '1') return true - if (process.env.FEATURE_SKILL_LEARNING === '1') return true - return false -} diff --git a/src/services/skillLearning/instinctParser.ts b/src/services/skillLearning/instinctParser.ts deleted file mode 100644 index 6307c010e..000000000 --- a/src/services/skillLearning/instinctParser.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface StoredInstinct { - key: string - prompt: string - count: number -} - -export function createInstinct( - prompt: string, - _options?: { sessionId?: string; projectId?: string }, -): StoredInstinct { - return { - key: `instinct-${Buffer.from(prompt).toString('base64').slice(0, 32)}`, - prompt, - count: 0, - } -} diff --git a/src/services/skillLearning/projectContext.ts b/src/services/skillLearning/projectContext.ts deleted file mode 100644 index f0a7fc7ae..000000000 --- a/src/services/skillLearning/projectContext.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { join } from 'node:path' - -export function getProjectStorageDir(cwd: string): string { - return join(cwd, '.claude') -} - -export function resolveProjectContext( - cwd: string, -): { projectId: string; projectName: string } | null { - return { - projectId: cwd.replace(/[^a-zA-Z0-9]/g, '_'), - projectName: cwd.split('/').pop() ?? 'unknown', - } -} diff --git a/src/services/skillLearning/skillGapStore.ts b/src/services/skillLearning/skillGapStore.ts deleted file mode 100644 index 04c4f323b..000000000 --- a/src/services/skillLearning/skillGapStore.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' -import { createHash } from 'node:crypto' -import { dirname, join } from 'node:path' -import type { SearchResult } from '../skillSearch/localSearch.js' -import { createInstinct, type StoredInstinct } from './instinctParser.js' -import { - getProjectStorageDir, - resolveProjectContext, -} from './projectContext.js' -import { generateSkillDraft, writeLearnedSkill } from './skillGenerator.js' -import type { - InstinctDomain, - SkillGapStatus, - SkillLearningProjectContext, -} from './types.js' - -export type SkillGapRecommendation = Pick< - SearchResult, - 'name' | 'description' | 'score' -> - -export type SkillGapMaterialization = - | { - type: 'draft' - name: string - skillPath: string - } - | { - type: 'active' - name: string - skillPath: string - } - -export type SkillGapRecord = { - key: string - prompt: string - count: number - draftHits: number - // Session IDs that have already contributed a draft hit for this gap — - // prevents one session from inflating `draftHits` beyond 1 and flipping the - // `draftHits >= 2` active-promotion gate by itself. - draftHitSessions: string[] - status: SkillGapStatus - sessionId: string - cwd: string - projectId: string - projectName: string - recommendations: SkillGapRecommendation[] - createdAt: string - updatedAt: string - draft?: SkillGapMaterialization - active?: SkillGapMaterialization -} - -// P0-2 hook: when outcome-aware observation lands, augment this with a -// lookup into observationStore for a matching `outcome: 'success'` tool_complete -// observation keyed by (sessionId, gap.key). Until then, draft promotion uses -// count/signal only. -const DRAFT_PROMOTION_COUNT = 2 -const ACTIVE_PROMOTION_COUNT = 4 -const ACTIVE_PROMOTION_DRAFT_HITS = 2 - -type SkillGapState = { - version: 1 - gaps: Record -} - -export type RecordSkillGapOptions = { - prompt: string - cwd?: string - sessionId?: string - recommendations?: SearchResult[] - project?: SkillLearningProjectContext - rootDir?: string -} - -export async function recordSkillGap( - options: RecordSkillGapOptions, -): Promise { - const prompt = options.prompt.trim() - if (!prompt) { - throw new Error('Cannot record an empty skill gap') - } - - const project = options.project ?? resolveProjectContext(options.cwd) - const state = await readSkillGapState(project, options.rootDir) - const key = buildSkillGapKey(prompt) - const now = new Date().toISOString() - const existing = state.gaps[key] - - const gap: SkillGapRecord = { - key, - prompt, - count: (existing?.count ?? 0) + 1, - draftHits: existing?.draftHits ?? 0, - draftHitSessions: existing?.draftHitSessions ?? [], - status: existing?.status ?? 'pending', - sessionId: options.sessionId ?? 'unknown-session', - cwd: options.cwd ?? project.cwd, - projectId: project.projectId, - projectName: project.projectName, - recommendations: (options.recommendations ?? []).slice(0, 5).map(r => ({ - name: r.name, - description: r.description, - score: r.score, - })), - createdAt: existing?.createdAt ?? now, - updatedAt: now, - draft: existing?.draft, - active: existing?.active, - } - - if (gap.status === 'rejected') { - state.gaps[key] = gap - await writeSkillGapState(project, state, options.rootDir) - return gap - } - - if (!gap.draft && shouldPromoteToDraft(gap)) { - gap.draft = await writeSkillGapDraft(gap, project) - gap.status = 'draft' - await clearRuntimeSkillCaches() - } - - if (gap.draft && !gap.active && shouldPromoteToActive(gap)) { - gap.active = await writeActiveSkillForGap(gap, project) - gap.status = 'active' - await clearRuntimeSkillCaches() - } - - state.gaps[key] = gap - await writeSkillGapState(project, state, options.rootDir) - return gap -} - -export async function readSkillGaps( - project = resolveProjectContext(), - rootDir?: string, -): Promise { - const state = await readSkillGapState(project, rootDir) - return Object.values(state.gaps).sort((a, b) => a.key.localeCompare(b.key)) -} - -export async function findGapKeyByDraftPath( - draftPath: string, - project = resolveProjectContext(), - rootDir?: string, -): Promise { - const state = await readSkillGapState(project, rootDir) - for (const gap of Object.values(state.gaps)) { - if (gap.draft?.skillPath === draftPath) return gap.key - } - return undefined -} - -export async function recordDraftHit( - key: string, - project = resolveProjectContext(), - rootDir?: string, - sessionId = 'unknown-session', -): Promise { - const state = await readSkillGapState(project, rootDir) - const gap = state.gaps[key] - if (!gap || !gap.draft || gap.active) return gap - // One draft hit per session: a single actor reloading the same draft - // repeatedly must not flip the draftHits>=2 gate. - const existingSessions = gap.draftHitSessions ?? [] - if (existingSessions.includes(sessionId)) return gap - const now = new Date().toISOString() - const updated: SkillGapRecord = { - ...gap, - draftHits: gap.draftHits + 1, - draftHitSessions: [...existingSessions, sessionId], - updatedAt: now, - } - - if (shouldPromoteToActive(updated)) { - updated.active = await writeActiveSkillForGap(updated, project) - updated.status = 'active' - await clearRuntimeSkillCaches() - } - - state.gaps[key] = updated - await writeSkillGapState(project, state, rootDir) - return updated -} - -export async function promoteGapToDraft( - key: string, - project = resolveProjectContext(), - rootDir?: string, -): Promise { - const state = await readSkillGapState(project, rootDir) - const gap = state.gaps[key] - if (!gap) return undefined - if (gap.status === 'rejected') return gap - if (gap.draft) return gap - const updated: SkillGapRecord = { - ...gap, - draft: await writeSkillGapDraft(gap, project), - status: 'draft', - updatedAt: new Date().toISOString(), - } - state.gaps[key] = updated - await writeSkillGapState(project, state, rootDir) - await clearRuntimeSkillCaches() - return updated -} - -export async function rejectSkillGap( - key: string, - project = resolveProjectContext(), - rootDir?: string, -): Promise { - const state = await readSkillGapState(project, rootDir) - const gap = state.gaps[key] - if (!gap) return undefined - const updated: SkillGapRecord = { - ...gap, - status: 'rejected', - updatedAt: new Date().toISOString(), - } - state.gaps[key] = updated - await writeSkillGapState(project, state, rootDir) - return updated -} - -export function shouldPromoteToDraft(gap: SkillGapRecord): boolean { - // Draft promotion now requires repeated occurrence. The legacy - // `isStrongReusableSignal` path was the cause of single-utterance Chinese - // exhortations being promoted straight to active — P0-2 will reintroduce - // outcome-aware signal once the observation layer supplies it. - return gap.count >= DRAFT_PROMOTION_COUNT -} - -export function shouldPromoteToActive(gap: SkillGapRecord): boolean { - if (!gap.draft) return false - return ( - gap.count >= ACTIVE_PROMOTION_COUNT || - gap.draftHits >= ACTIVE_PROMOTION_DRAFT_HITS - ) -} - -async function writeSkillGapDraft( - gap: SkillGapRecord, - project: SkillLearningProjectContext, -): Promise { - const instinct = createGapInstinct(gap, 'pending') - const draftsRoot = join( - project.projectRoot ?? project.cwd, - '.claude', - 'skills', - '.drafts', - ) - const draft = generateSkillDraft([instinct], { - cwd: project.projectRoot ?? project.cwd, - outputRoot: draftsRoot, - scope: 'project', - name: `draft-${buildNameFragment(gap.prompt)}`, - description: - 'Draft learned skill candidate. Promote after repeated evidence or explicit user correction.', - }) - const skillFile = join(draft.outputPath, 'SKILL.md') - if (!existsSync(skillFile)) { - await writeLearnedSkill({ - ...draft, - content: - draft.content + - '\n## Promotion Rule\n\nDo not move this draft into active skills until the same gap repeats or the user explicitly confirms this should become reusable.\n', - }) - } - return { type: 'draft', name: draft.name, skillPath: skillFile } -} - -async function writeActiveSkillForGap( - gap: SkillGapRecord, - project: SkillLearningProjectContext, -): Promise { - const instinct = createGapInstinct(gap, 'active') - const draft = generateSkillDraft([instinct], { - cwd: project.projectRoot ?? project.cwd, - scope: 'project', - name: buildNameFragment(gap.prompt), - description: buildGapAction(gap.prompt), - }) - const skillFile = join(draft.outputPath, 'SKILL.md') - if (!existsSync(skillFile)) { - await writeLearnedSkill(draft) - } - return { type: 'active', name: draft.name, skillPath: skillFile } -} - -function createGapInstinct( - gap: SkillGapRecord, - status: StoredInstinct['status'], -): StoredInstinct { - return createInstinct({ - trigger: `When the user asks for ${summarize(gap.prompt, 120)}`, - action: buildGapAction(gap.prompt), - confidence: status === 'active' ? 0.82 : 0.55, - domain: inferDomain(gap.prompt), - source: 'session-observation', - scope: 'project', - projectId: gap.projectId, - projectName: gap.projectName, - evidence: [ - `Skill gap prompt: ${summarize(gap.prompt, 180)}`, - `No high-confidence active skill was auto-loaded.`, - `Observed ${gap.count} time(s).`, - ], - status, - }) -} - -function buildGapAction(prompt: string): string { - if ( - /feature\s*\(|feature flag|flag_name|stub|no-op|noop|最小实现/i.test(prompt) - ) { - return 'Audit feature flags by scanning feature() call sites, excluding generated/dependency noise, classifying each candidate as stub, shell, MVP, or thin-toggle, and writing an evidence-backed document.' - } - if (/skill|技能|学习|进化|evolve|learning/i.test(prompt)) { - return 'Run skill discovery first; auto-load only high-confidence matching skills; record a skill gap when none match; promote repeated or corrected gaps into learned skills.' - } - if (/test|测试|stub|调用链|参数/i.test(prompt)) { - return 'Infer tests from existing files, parameters, exports, and call chains before simplifying mocks or inventing behavior.' - } - return `Reuse the workflow learned from this prompt: ${summarize(prompt, 180)}.` -} - -function inferDomain(prompt: string): InstinctDomain { - const text = prompt.toLowerCase() - if (/test|测试|stub|fixture|断言/.test(text)) return 'testing' - if (/error|bug|fix|失败|错误|修复|debug/.test(text)) return 'debugging' - if (/security|安全|漏洞|secret|token/.test(text)) return 'security' - if (/git|commit|branch|pr\b/.test(text)) return 'git' - if (/style|lint|format|命名|规范/.test(text)) return 'code-style' - return 'workflow' -} - -async function readSkillGapState( - project: SkillLearningProjectContext, - rootDir?: string, -): Promise { - const path = getSkillGapStatePath(project, rootDir) - let raw: string - try { - raw = await readFile(path, 'utf8') - } catch (error) { - // Only treat "file doesn't exist yet" as empty state. Every other error - // (EACCES, EIO, disk full, etc.) must throw — swallowing them here would - // let a subsequent write persist {} and zero out all gap records. - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { version: 1, gaps: {} } - } - throw error - } - try { - return migrateLegacyGapState(JSON.parse(raw) as SkillGapState) - } catch { - // Corrupt/truncated JSON — don't silently reset. Backup and start fresh, - // so the crash isn't masked and the data can be recovered manually. - const backup = `${path}.corrupt-${Date.now()}` - try { - await writeFile(backup, raw, 'utf8') - } catch { - /* best effort */ - } - return { version: 1, gaps: {} } - } -} - -function migrateLegacyGapState(state: SkillGapState): SkillGapState { - const migrated: Record = {} - for (const [key, record] of Object.entries(state.gaps ?? {})) { - const legacy = record as Partial & { - status?: unknown - } - const draftHits = - typeof legacy.draftHits === 'number' && Number.isFinite(legacy.draftHits) - ? legacy.draftHits - : 0 - const count = typeof legacy.count === 'number' ? legacy.count : 1 - const normalizedStatus = normalizeLegacyStatus(legacy.status) - const hasDraftFile = Boolean(legacy.draft) - const hasActiveFile = Boolean(legacy.active) - - let status: SkillGapStatus = normalizedStatus - if (status === 'draft' && count < DRAFT_PROMOTION_COUNT && !hasDraftFile) { - // Legacy first-call-writes-draft artifact with no file on disk yet. - status = 'pending' - } - if (status === 'active' && !hasActiveFile) { - status = hasDraftFile ? 'draft' : 'pending' - } - - const draftHitSessions = Array.isArray(legacy.draftHitSessions) - ? legacy.draftHitSessions.filter( - (session): session is string => typeof session === 'string', - ) - : [] - migrated[key] = { - ...(record as SkillGapRecord), - count, - draftHits, - draftHitSessions, - status, - } - } - return { version: 1, gaps: migrated } -} - -function normalizeLegacyStatus(value: unknown): SkillGapStatus { - if ( - value === 'pending' || - value === 'draft' || - value === 'active' || - value === 'rejected' - ) { - return value - } - return 'pending' -} - -async function writeSkillGapState( - project: SkillLearningProjectContext, - state: SkillGapState, - rootDir?: string, -): Promise { - const path = getSkillGapStatePath(project, rootDir) - await mkdir(dirname(path), { recursive: true }) - // Atomic write: temp + rename. A direct writeFile leaves a truncated file - // on crash mid-write; combined with the (now strict) readSkillGapState, - // that would lose gap records. - const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}` - await writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8') - await rename(tmpPath, path) -} - -function getSkillGapStatePath( - project: SkillLearningProjectContext, - rootDir?: string, -): string { - const base = rootDir - ? project.projectId === 'global' - ? join(rootDir, 'global') - : join(rootDir, 'projects', project.projectId) - : getProjectStorageDir(project.projectId) - return join(base, 'skill-gaps.json') -} - -function buildSkillGapKey(prompt: string): string { - return `${buildNameFragment(prompt)}-${hash(prompt).slice(0, 8)}` -} - -function buildNameFragment(prompt: string): string { - const mapped = prompt - .replaceAll('技能', ' skill ') - .replaceAll('学习', ' learning ') - .replaceAll('进化', ' evolution ') - .replaceAll('测试', ' testing ') - .replaceAll('最小实现', ' minimal implementation ') - .toLowerCase() - const stop = new Set([ - 'the', - 'and', - 'for', - 'with', - 'this', - 'that', - 'user', - 'about', - 'feature', - 'flag', - 'name', - ]) - const words = (mapped.match(/[a-z0-9][a-z0-9_-]{2,}/g) ?? []) - .filter(word => !stop.has(word)) - .slice(0, 5) - const value = words.join('-') || 'learned-gap' - return value.slice(0, 54).replace(/-+$/g, '') -} - -function summarize(value: string, max: number): string { - return value.replace(/\s+/g, ' ').trim().slice(0, max) -} - -function hash(value: string): string { - return createHash('sha1').update(value).digest('hex') -} - -async function clearRuntimeSkillCaches(): Promise { - try { - const { clearCommandsCache } = await import('../../commands.js') - clearCommandsCache() - } catch { - // Best effort only; generated skill files are still available next process. - } -} diff --git a/src/services/skillLearning/skillGenerator.ts b/src/services/skillLearning/skillGenerator.ts deleted file mode 100644 index cb2b158a0..000000000 --- a/src/services/skillLearning/skillGenerator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { SkillGapRecord } from './skillGapStore.js' - -export async function generateSkillDraft( - _gap: SkillGapRecord, -): Promise<{ name: string; skillPath: string } | null> { - return null -} - -export async function writeLearnedSkill(_skillPath: string): Promise { - // stub -} diff --git a/src/services/skillLearning/types.ts b/src/services/skillLearning/types.ts deleted file mode 100644 index 3e4db1328..000000000 --- a/src/services/skillLearning/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type InstinctDomain = string -export type SkillGapStatus = 'pending' | 'draft' | 'active' | 'closed' -export interface SkillLearningProjectContext { - cwd: string - projectId: string - projectName: string -} diff --git a/src/services/skillSearch/prefetch.ts b/src/services/skillSearch/prefetch.ts index a229a290a..769bb7a65 100644 --- a/src/services/skillSearch/prefetch.ts +++ b/src/services/skillSearch/prefetch.ts @@ -15,9 +15,9 @@ import { join } from 'node:path' import { parseFrontmatter } from '../../utils/frontmatterParser.js' /** - * Per-session memoization to avoid re-emitting the same skill discovery / - * gap signal twice. Each Set is bounded to keep long-running sessions from - * monotonically accumulating skill names and signal keys forever (which + * Per-session memoization to avoid re-emitting the same skill discovery + * signal twice. Each Set is bounded to keep long-running sessions from + * monotonically accumulating skill names forever (which * was the original session-scoped-but-unbounded design). * * FIFO eviction by insertion order — once the cap is hit, the oldest @@ -28,7 +28,6 @@ import { parseFrontmatter } from '../../utils/frontmatterParser.js' const SESSION_TRACKING_MAX = 1000 const SESSION_TRACKING_TRIM_TO = 750 const discoveredThisSession = new Set() -const recordedGapSignals = new Set() function addBoundedSessionEntry(set: Set, value: string): void { set.add(value) @@ -201,49 +200,14 @@ async function markAutoLoadedSkill( } async function maybeRecordSkillGap( - queryText: string, - results: SearchResult[], - context: ToolUseContext, - trigger: DiscoverySignal['trigger'], + _queryText: string, + _results: SearchResult[], + _context: ToolUseContext, + _trigger: DiscoverySignal['trigger'], ): Promise { - if (trigger !== 'user_input') return undefined - if (!queryText.trim()) return undefined - - const gapSignalKey = `${trigger}:${queryText.trim().toLowerCase()}` - if (recordedGapSignals.has(gapSignalKey)) return undefined - addBoundedSessionEntry(recordedGapSignals, gapSignalKey) - - try { - const [{ isSkillLearningEnabled }, { recordSkillGap }] = await Promise.all([ - import('../skillLearning/featureCheck.js'), - import('../skillLearning/skillGapStore.js'), - ]) - if (!isSkillLearningEnabled()) return undefined - const gap = await recordSkillGap({ - prompt: queryText, - cwd: - ((context as Record).cwd as string) ?? process.cwd(), - sessionId: - ((context as Record).sessionId as string) ?? - 'unknown-session', - recommendations: results, - }) - const status = gap.status - if (status !== 'pending' && status !== 'draft' && status !== 'active') { - return undefined - } - return { - key: gap.key, - status, - draftName: gap.draft?.name, - draftPath: gap.draft?.skillPath, - activeName: gap.active?.name, - activePath: gap.active?.skillPath, - } - } catch (error) { - logForDebugging(`[skill-search] skill gap learning error: ${error}`) - return undefined - } + // skillLearning subsystem has been removed. This function is preserved + // as a stable signature so callers don't need to be null-checked. + return undefined } export async function startSkillDiscoveryPrefetch( diff --git a/vite.config.ts b/vite.config.ts index e22949166..8dd738bad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,7 +18,6 @@ const acknowledgedBuildWarnings = [ 'src/utils/swarm/backends/registry.ts', 'src/utils/toolSearch.ts', 'src/utils/hooks.ts', - 'src/services/skillLearning/sessionObserver.ts', 'src/utils/settings/changeDetector.ts', ]