import type { ExperienceRule } from '../agent/experience-rules.js' import { mkdirSync, readFileSync, existsSync } from 'node:fs' import { atomicWriteFileSync } from '../shared/atomic-write' import { join, dirname } from 'node:path' import { MANAGED_RULES } from './crsi-managed-rules' import { miphamHome } from './paths.ts' export interface ToolRule { id: string toolName: string category: string match: (params: Record) => boolean fix: (params: Record) => { modified: Record warning: string } source: 'builtin' | 'pattern-analyzer' | 'manual' | 'managed' enabled: boolean } const BUILTIN_RULES: ToolRule[] = [ { id: 'rule-timeout-bash-heavy', toolName: 'Bash', category: 'timeout', match: (p: Record) => { const cmd = String(p.command ?? '') const heavy = /npm (install|ci|test)|docker build|pnpm install|cargo build|brew install/.test( cmd, ) if (!heavy) return false const timeout = (p as Record).timeout as number | undefined return !timeout || timeout < 300_000 }, fix: (p: Record) => { const prevTimeout = (p as Record).timeout as number | undefined return { modified: { ...p, timeout: 300_000 }, warning: `⏱️ timeout 已从 ${prevTimeout || 'default'}ms 自动提升至 300000ms(该命令类型历史超时率 > 50%)`, } }, source: 'builtin', enabled: true, }, { id: 'rule-git-force-protection', toolName: 'Bash', category: 'tool-params', match: (p: Record) => { const cmd = String(p.command ?? '') return /git (push|reset) .*--force/.test(cmd) && !p.dangerouslyDisableSandbox }, fix: (p: Record) => ({ modified: p, warning: '⚠️ 检测到 git --force 操作。如需执行请设置 dangerouslyDisableSandbox: true', }), source: 'builtin', enabled: true, }, ] export class ExperienceRuleEngine { private rules: ToolRule[] private storePath: string constructor(storeDir: string = miphamHome('rule-engine')) { this.rules = [...BUILTIN_RULES, ...MANAGED_RULES].map((r) => ({ ...r })) this.storePath = join(storeDir, 'rules.json') this.load() } register(rule: ToolRule): void { // Replace if same ID exists, otherwise append const idx = this.rules.findIndex((r) => r.id === rule.id) if (idx !== -1) { this.rules[idx] = rule } else { this.rules.push(rule) } this.persist() } intercept( toolName: string, params: Record, ): { modified: Record; warnings: string[] } { let modified = params const warnings: string[] = [] for (const rule of this.rules) { if (!rule.enabled) continue if (rule.toolName !== toolName) continue try { if (rule.match(modified)) { const result = rule.fix(modified) modified = result.modified if (result.warning) { warnings.push(`[rule:${rule.id}] ${result.warning}`) } } } catch { // Rule match/fix failures never block execution } } return { modified, warnings } } convertFromExperienceRules(experienceRules: ExperienceRule[]): ToolRule[] { return experienceRules.map((er): ToolRule => { // Map experience rule category to tool name const toolNameMap: Record = { timeout: 'Bash', 'tool-params': 'Bash', import: 'Write', search: 'Grep', semantic: 'Bash', } const toolName = toolNameMap[er.category] || 'Bash' return { id: er.id, toolName, category: er.category, match: (p: Record): boolean => { if (er.category === 'timeout') { const cmd = String(p.command ?? '') const heavy = /npm|docker|pnpm|cargo|brew|install|build/.test(cmd) if (!heavy) return false const timeout = (p as Record).timeout as number | undefined return !timeout || timeout < 300_000 } // Default: always match for the given tool return true }, fix: (p: Record) => ({ modified: { ...p, timeout: 300_000 }, warning: `⏱️ [auto-rule] ${er.action} (${er.evidence.failureCount} 次历史失败)`, }), source: 'pattern-analyzer', enabled: true, } }) } getActiveRules(): ToolRule[] { return this.rules.filter((r) => r.enabled) } setRuleEnabled(id: string, enabled: boolean): void { const rule = this.rules.find((r) => r.id === id) if (rule) { rule.enabled = enabled this.persist() } } /** Persist runtime rules to disk. Builtin 与 managed 规则永不落盘(源码即真相)。 */ persist(): void { const nonBuiltin = this.rules.filter((r) => r.source !== 'builtin' && r.source !== 'managed') const dir = dirname(this.storePath) mkdirSync(dir, { recursive: true }) atomicWriteFileSync(this.storePath, JSON.stringify(nonBuiltin, null, 2), { mode: 0o644 }) } /** Load persisted runtime rules from disk. Rejects rules whose IDs conflict with builtin/managed. */ load(): void { if (!existsSync(this.storePath)) return try { const raw = JSON.parse(readFileSync(this.storePath, 'utf-8')) as ToolRule[] const reservedIds = new Set([...BUILTIN_RULES, ...MANAGED_RULES].map((r) => r.id)) for (const rule of raw) { // Reject if a builtin/managed rule with the same ID exists (source rules always win) if (reservedIds.has(rule.id)) continue this.rules.push(rule) } } catch { // Corrupt file — start fresh with only builtins } } }