feat: 整合功能恢复与技能学习闭环(含 ECC v2.1 parity + Opus 4.7 接入 + prompt 工程优化)

主要变更:
- Skill Learning 闭环系统 (9/9 AC)
- Opus 4.7 模型层接入 + adaptive thinking
- Prompt 工程优化 (64 审计测试)
- Agent Teams 简化门控 (默认启用)
- Windows Terminal 后端修复 (EncodedCommand/WT_SESSION)
- TF-IDF 技能搜索精准化 (字段加权/CJK 优化)
- Autonomy 系统 (/autonomy 命令)
- ACP 协议完整实现
- mock.module 泄漏修复 (CI 全绿)
- 152+ lint/type 修复
This commit is contained in:
unraid
2026-04-22 16:07:42 +08:00
parent 711927f01b
commit 95fece4b51
316 changed files with 39611 additions and 14298 deletions

View File

@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { call } from '../skill-learning.js'
import {
recordSkillGap,
saveInstinct,
createInstinct,
resolveProjectContext,
} from '../../../services/skillLearning/index.js'
let root: string
const originalEnv = { ...process.env }
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'skill-learning-command-'))
process.env = { ...originalEnv }
process.env.CLAUDE_SKILL_LEARNING_HOME = root
process.env.CLAUDE_CONFIG_DIR = join(root, 'config')
process.env.SKILL_LEARNING_ENABLED = '1'
})
afterEach(() => {
process.env = { ...originalEnv }
rmSync(root, { recursive: true, force: true })
})
describe('skill-learning command', () => {
test('status reports observations and instincts', async () => {
const result = await call('status', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Skill Learning status')
expect(result.value).toContain('Observations: 0')
}
})
test('promote (no args) prints usage and candidate summary', async () => {
const result = await call('promote', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Promotion candidates')
expect(result.value).toContain('promote gap')
expect(result.value).toContain('promote instinct')
}
})
test('promote gap <key> promotes a pending gap to draft', async () => {
const project = resolveProjectContext(process.cwd())
const gap = await recordSkillGap({
prompt: 'refactor the api gateway',
cwd: process.cwd(),
project,
rootDir: root,
})
expect(gap.status).toBe('pending')
const result = await call(`promote gap ${gap.key}`, {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Promoted gap')
expect(result.value).toContain('status=draft')
}
})
test('promote gap <unknown-key> reports not found', async () => {
const result = await call('promote gap does-not-exist', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('No gap found')
}
})
test('promote instinct <id> copies a project instinct to global scope', async () => {
const project = resolveProjectContext(process.cwd())
const instinct = createInstinct({
trigger: 'when committing',
action: 'run tests first',
confidence: 0.85,
domain: 'testing',
source: 'session-observation',
scope: 'project',
projectId: project.projectId,
projectName: project.projectName,
evidence: ['observed twice'],
})
await saveInstinct(instinct, { project, rootDir: root })
const result = await call(`promote instinct ${instinct.id}`, {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Promoted instinct')
expect(result.value).toContain('global scope')
}
})
test('projects lists known project scopes', async () => {
// Resolving once registers the current project in the registry.
resolveProjectContext(root)
const result = await call('projects', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(
result.value.includes('Known project scopes') ||
result.value.includes('No known project scopes'),
).toBe(true)
}
})
test('default help mentions promote and projects, no write-fixture', async () => {
const result = await call('unknown-sub', {} as any)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('promote')
expect(result.value).toContain('projects')
expect(result.value).not.toContain('write-fixture')
}
})
test('ingest imports transcript observations and instincts', async () => {
const transcript = join(root, 'session.jsonl')
writeFileSync(
transcript,
JSON.stringify({
type: 'user',
sessionId: 's1',
cwd: root,
message: { role: 'user', content: '不要 mock用 testing-library' },
}) + '\n',
)
// Pass --min-session-length=0 so the 1-line test transcript is not skipped
// by the ECC-parity gate (default threshold: 10 observations).
const result = await call(
`ingest ${transcript} --min-session-length=0`,
{} as any,
)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Ingested')
expect(result.value).toContain('saved 1 instincts')
}
})
})

View File

@@ -0,0 +1,15 @@
import type { Command } from '../../commands.js'
import { isSkillLearningEnabled } from '../../services/skillLearning/featureCheck.js'
const skillLearning = {
type: 'local-jsx',
name: 'skill-learning',
description: 'Manage skill learning (observe, analyze, evolve)',
argumentHint:
'[start|stop|about|status|ingest|evolve|export|import|prune|promote|projects]',
isEnabled: () => isSkillLearningEnabled(),
isHidden: false,
load: () => import('./skillPanel.js'),
} satisfies Command
export default skillLearning

View File

@@ -0,0 +1,310 @@
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,
importInstincts,
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 <transcript.jsonl> [--min-session-length=<n>]',
}
}
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<ReturnType<typeof loadInstincts>>) =>
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 <instincts.json> [--scope=<scope>] [--min-conf=<n>] [--domain=<d>] [--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<typeof loadInstincts>
>
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 <gap-key> # pending gap -> draft',
' /skill-learning promote instinct <instinct-id> # 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 <gap-key>',
}
}
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 <instinct-id>',
}
}
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 <gap-key>|instinct <instinct-id>]',
}
}
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<T extends number | undefined>(
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
}

View File

@@ -0,0 +1,197 @@
import React, { useMemo, useState } from 'react';
import { Box, Text, useInput } from '@anthropic/ink';
import { Dialog } from '@anthropic/ink';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { isSkillLearningEnabled } from '../../services/skillLearning/featureCheck.js';
type SkillAction = {
label: string;
description: string;
run: () => Promise<string>;
};
const ACTION_LABEL_COLUMN_WIDTH = 28;
const ABOUT_TEXT = `# Skill Learning (自动学习)
Skill Learning 是一个闭环学习系统,通过观察用户的操作模式自动提取直觉(instinct)
并在达到阈值后生成可复用的 skill 文件、agent 和 command。
## 工作流程
1. **Observe** — 记录每轮对话中的工具调用、用户纠正、错误解决模式
2. **Analyze** — 使用启发式或 LLM 后端分析观察数据,提取 instinct candidate
3. **Evolve** — 将高置信度 instinct 聚类,生成 skill/agent/command 候选
4. **Lifecycle** — 对生成的 skill 进行去重、版本比较、归档或替换
## 子命令
- /skill-learning status — 查看当前项目的观察和直觉数量
- /skill-learning ingest — 从 transcript 导入观察数据
- /skill-learning evolve — 生成 skill 候选 (--generate 写入磁盘)
- /skill-learning export — 导出 instinct 为 JSON
- /skill-learning import — 导入 instinct JSON
- /skill-learning prune — 清理过期的 pending instinct
- /skill-learning promote — 将 instinct/gap 提升为全局范围
- /skill-learning projects — 列出所有已知的项目范围
## 启用方式
- SKILL_LEARNING_ENABLED=1 或 FEATURE_SKILL_LEARNING=1
- 状态: ${isSkillLearningEnabled() ? '已启用' : '未启用'}
`;
async function getStatusText(): Promise<string> {
const { readObservations, loadInstincts, resolveProjectContext } = await import(
'../../services/skillLearning/index.js'
);
const project = resolveProjectContext(process.cwd());
const [observations, instincts] = await Promise.all([readObservations({ project }), loadInstincts({ project })]);
return [
`Skill Learning status for ${project.projectName} (${project.projectId})`,
`Observations: ${observations.length}`,
`Instincts: ${instincts.length}`,
'',
`Skill Learning: ${isSkillLearningEnabled() ? 'enabled' : 'disabled'}`,
].join('\n');
}
async function startSkillLearning(): Promise<string> {
const lines: string[] = [];
if (!isSkillLearningEnabled()) {
process.env.SKILL_LEARNING_ENABLED = '1';
lines.push('Skill Learning: enabled (SKILL_LEARNING_ENABLED=1)');
} else {
lines.push('Skill Learning: already enabled');
}
try {
const { initSkillLearning } = await import('../../services/skillLearning/runtimeObserver.js');
initSkillLearning();
lines.push('Runtime observer: initialized');
} catch {
lines.push('Runtime observer: init skipped (not available)');
}
return lines.join('\n');
}
async function stopSkillLearning(): Promise<string> {
const lines: string[] = [];
if (isSkillLearningEnabled()) {
process.env.SKILL_LEARNING_ENABLED = '0';
process.env.CLAUDE_SKILL_LEARNING_DISABLE = '1';
lines.push('Skill Learning: disabled (SKILL_LEARNING_ENABLED=0)');
} else {
lines.push('Skill Learning: already disabled');
}
return lines.join('\n');
}
function SkillPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
useRegisterOverlay('skill-panel');
const [selectedIndex, setSelectedIndex] = useState(0);
const actions = useMemo<SkillAction[]>(
() => [
{
label: 'Status',
description: 'Show skill learning status for current project',
run: getStatusText,
},
{
label: 'Start',
description: 'Enable skill learning for this session',
run: startSkillLearning,
},
{
label: 'Stop',
description: 'Disable skill learning for this session',
run: stopSkillLearning,
},
{
label: 'About',
description: 'Detailed description of skill learning features',
run: () => Promise.resolve(ABOUT_TEXT),
},
],
[],
);
const selectCurrent = () => {
const action = actions[selectedIndex];
if (!action) return;
void action.run().then(result => {
onDone(result, { display: 'system' });
});
};
useInput((_input, key) => {
if (key.upArrow) {
setSelectedIndex(index => Math.max(0, index - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
return;
}
if (key.return) {
selectCurrent();
}
});
return (
<Dialog
title="Skill Learning"
subtitle={`${actions.length} actions`}
onCancel={() => onDone('Skill panel dismissed', { display: 'system' })}
color="background"
hideInputGuide
>
<Box flexDirection="column">
{actions.map((action, index) => (
<Box key={action.label} flexDirection="row">
<Text>{`${index === selectedIndex ? '' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
<Text dimColor>{action.description}</Text>
</Box>
))}
<Box marginTop={1}>
<Text dimColor>/ select · Enter run · Esc close</Text>
</Box>
</Box>
</Dialog>
);
}
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
const trimmed = args?.trim() ?? '';
if (trimmed === 'start') {
onDone(await startSkillLearning(), { display: 'system' });
return null;
}
if (trimmed === 'stop') {
onDone(await stopSkillLearning(), { display: 'system' });
return null;
}
if (trimmed === 'about') {
onDone(ABOUT_TEXT, { display: 'system' });
return null;
}
if (trimmed === 'status') {
onDone(await getStatusText(), { display: 'system' });
return null;
}
if (trimmed) {
const { call: textCall } = await import('./skill-learning.js');
const result = await textCall(trimmed, {} as any);
if (result && typeof result === 'object' && 'value' in result) {
onDone((result as { value: string }).value, { display: 'system' });
}
return null;
}
return <SkillPanel onDone={onDone} />;
}