import { join } from 'node:path' import type { LocalCommandCall } from '../../types/command.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { analyzeObservations, applySkillLifecycleDecision, compareExistingSkills, decideSkillLifecycle, exportInstincts, findPromotionCandidates, generateSkillCandidates, ingestTranscript, listKnownProjects, loadInstincts, promoteGapToDraft, prunePendingInstincts, readObservations, readSkillGaps, resolveProjectContext, saveInstinct, upsertInstinct, } from '../../services/skillLearning/index.js' export const call: LocalCommandCall = async ( args, ): Promise<{ type: 'text'; value: string }> => { const parts = args.trim().split(/\s+/).filter(Boolean) const sub = parts[0] ?? 'status' const project = resolveProjectContext(process.cwd()) const rootDir = process.env.CLAUDE_SKILL_LEARNING_HOME const options = { project, rootDir } switch (sub) { case 'status': { const [observations, instincts] = await Promise.all([ readObservations(options), loadInstincts(options), ]) return { type: 'text', value: [ `Skill Learning status for ${project.projectName} (${project.projectId})`, `Observations: ${observations.length}`, `Instincts: ${instincts.length}`, ].join('\n'), } } case 'ingest': { const transcript = parts[1] if (!transcript) { return { type: 'text', value: 'Usage: /skill-learning ingest [--min-session-length=]', } } const minSessionLength = parseFlagNumber( parts, '--min-session-length', 10, ) const observations = await ingestTranscript(transcript, options) if (observations.length < minSessionLength) { return { type: 'text', value: `Session too short for learning (${observations.length} < min=${minSessionLength}). Skipping instinct extraction.`, } } const instincts = analyzeObservations(observations) const saved = [] for (const instinct of instincts) { saved.push(await upsertInstinct(instinct, options)) } return { type: 'text', value: `Ingested ${observations.length} observations and saved ${saved.length} instincts.`, } } case 'evolve': { const generate = parts.includes('--generate') const instincts = await loadInstincts(options) const drafts = generateSkillCandidates(instincts, { cwd: process.cwd() }) const written = [] if (generate) { for (const draft of drafts) { const roots = [ join(process.cwd(), '.claude', 'skills'), join(getClaudeConfigHomeDir(), 'skills'), ] const existing = await compareExistingSkills(draft, roots) const decision = decideSkillLifecycle(draft, existing) const result = await applySkillLifecycleDecision(decision) written.push( `${decision.type}: ${result.activePath ?? result.archivedPath ?? result.deletedPath ?? 'no active write'}`, ) } } return { type: 'text', value: generate ? `Generated ${written.length} learned skill(s):\n${written.join('\n')}` : `Found ${drafts.length} skill candidate(s). Use --generate to write them.`, } } case 'export': { const output = parts[1] ?? 'skill-learning-instincts.json' const scope = parseFlagString(parts, '--scope') const minConf = parseFlagNumber(parts, '--min-conf', undefined) const domain = parseFlagString(parts, '--domain') const filter = (instincts: Awaited>) => instincts.filter(i => { if (scope && i.scope !== scope) return false if (minConf !== undefined && i.confidence < minConf) return false if (domain && i.domain !== domain) return false return true }) const all = await loadInstincts(options) const filtered = filter(all) if (filtered.length !== all.length) { await exportInstincts(output, options) // Re-write with filtered payload to honor filter args. const { writeFile } = await import('node:fs/promises') await writeFile(output, `${JSON.stringify(filtered, null, 2)}\n`) } else { await exportInstincts(output, options) } const parts2: string[] = [ `Exported ${filtered.length} instincts to ${output}`, ] if (scope || minConf !== undefined || domain) { const filters: string[] = [] if (scope) filters.push(`scope=${scope}`) if (minConf !== undefined) filters.push(`min-conf=${minConf}`) if (domain) filters.push(`domain=${domain}`) parts2.push(`(filters: ${filters.join(', ')})`) } return { type: 'text', value: parts2.join(' ') } } case 'import': { const input = parts[1] if (!input) { return { type: 'text', value: 'Usage: /skill-learning import [--scope=] [--min-conf=] [--domain=] [--dry-run]', } } const scope = parseFlagString(parts, '--scope') const minConf = parseFlagNumber(parts, '--min-conf', undefined) const domain = parseFlagString(parts, '--domain') const dryRun = parts.includes('--dry-run') // Read + filter first so --dry-run can truly skip persistence. The // previous `importInstincts(...)` call wrote to disk before branching // on --dry-run, which defeated the purpose of the flag. const { readFile: readFileFs } = await import('node:fs/promises') const parsed = JSON.parse(await readFileFs(input, 'utf8')) as Awaited< ReturnType > const filtered = parsed.filter(i => { if (scope && i.scope !== scope) return false if (minConf !== undefined && i.confidence < minConf) return false if (domain && i.domain !== domain) return false return true }) if (dryRun) { return { type: 'text', value: `Dry run: would import ${filtered.length}/${parsed.length} instincts.`, } } for (const instinct of filtered) { await upsertInstinct(instinct, options) } return { type: 'text', value: `Imported ${filtered.length}/${parsed.length} instincts.`, } } case 'prune': { const maxAgeIndex = parts.indexOf('--max-age') const maxAge = maxAgeIndex >= 0 && parts[maxAgeIndex + 1] ? Number(parts[maxAgeIndex + 1]) : 30 const pruned = await prunePendingInstincts(maxAge, options) return { type: 'text', value: `Pruned ${pruned.length} pending instincts.`, } } case 'promote': { const target = parts[1] if (!target) { const gaps = await readSkillGaps(project, rootDir) const instincts = await loadInstincts(options) const candidates = findPromotionCandidates(instincts) const lines = [ `Promotion candidates for ${project.projectName} (${project.projectId}):`, `Pending gaps: ${gaps.filter(g => g.status === 'pending').length}`, `Global-eligible instincts (>=2 projects, avg confidence >=0.8): ${candidates.length}`, '', 'Usage:', ' /skill-learning promote gap # pending gap -> draft', ' /skill-learning promote instinct # project instinct -> global', ] return { type: 'text', value: lines.join('\n') } } if (target === 'gap') { const gapKey = parts[2] if (!gapKey) { return { type: 'text', value: 'Usage: /skill-learning promote gap ', } } const updated = await promoteGapToDraft(gapKey, project, rootDir) if (!updated) { return { type: 'text', value: `No gap found for key "${gapKey}".` } } return { type: 'text', value: `Promoted gap ${gapKey} to status=${updated.status} (draft=${updated.draft?.skillPath ?? 'none'}).`, } } if (target === 'instinct') { const instinctId = parts[2] if (!instinctId) { return { type: 'text', value: 'Usage: /skill-learning promote instinct ', } } const projectInstincts = await loadInstincts(options) const match = projectInstincts.find(i => i.id === instinctId) if (!match) { return { type: 'text', value: `No project-scoped instinct found for id "${instinctId}".`, } } if (match.scope === 'global') { return { type: 'text', value: `Instinct ${instinctId} is already global.`, } } const globalCopy = { ...match, scope: 'global' as const } await saveInstinct(globalCopy, { scope: 'global', rootDir }) return { type: 'text', value: `Promoted instinct ${instinctId} to global scope.`, } } return { type: 'text', value: 'Usage: /skill-learning promote [gap |instinct ]', } } case 'projects': { const projects = listKnownProjects() if (projects.length === 0) { return { type: 'text', value: 'No known project scopes yet.' } } const lines = ['Known project scopes:'] for (const record of projects) { const projectOptions = { project: record, rootDir } const [instincts, observations] = await Promise.all([ loadInstincts(projectOptions), readObservations(projectOptions), ]) lines.push( `- ${record.projectName} (${record.projectId}) — instincts: ${instincts.length}, observations: ${observations.length}, lastSeen: ${record.lastSeenAt}`, ) } return { type: 'text', value: lines.join('\n') } } default: return { type: 'text', value: 'Usage: /skill-learning [status|ingest|evolve|export|import|prune|promote|projects]', } } } function parseFlagString(parts: string[], flag: string): string | undefined { const eqForm = parts.find(p => p.startsWith(`${flag}=`)) if (eqForm) return eqForm.slice(flag.length + 1) || undefined const idx = parts.indexOf(flag) if (idx >= 0 && parts[idx + 1] && !parts[idx + 1].startsWith('--')) { return parts[idx + 1] } return undefined } function parseFlagNumber( parts: string[], flag: string, fallback: T, ): number | T { const raw = parseFlagString(parts, flag) if (raw === undefined) return fallback const value = Number(raw) return Number.isFinite(value) ? value : fallback }