@miphamai/cli 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -5,7 +5,9 @@ import { homedir } from 'node:os'
5
5
  import { ContextManager } from '../core/context'
6
6
  import type { ToolDefinition } from '../shared/index.ts'
7
7
  import type { AgentDefinition } from './types'
8
+ import type { CrsiConfig } from '../shared/index.ts'
8
9
  import { AgentExperience } from './agent-experience'
10
+ import { ExperienceRuleExtractor } from './experience-rules.js'
9
11
 
10
12
  export interface AgentContextResult {
11
13
  context: ContextManager
@@ -15,8 +17,14 @@ export interface AgentContextResult {
15
17
  /**
16
18
  * Load agent memory files from the appropriate scope directory.
17
19
  * Returns combined content for injection into the system prompt.
20
+ *
21
+ * @param crsiRuleInjection — when false, skip experience rule extraction (CRSI feature flag).
18
22
  */
19
- function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local'): string {
23
+ function loadAgentMemory(
24
+ agentName: string,
25
+ scope: 'user' | 'project' | 'local',
26
+ crsiRuleInjection = true,
27
+ ): string {
20
28
  let memoryDir: string
21
29
  const home = homedir()
22
30
 
@@ -56,63 +64,16 @@ function loadAgentMemory(agentName: string, scope: 'user' | 'project' | 'local')
56
64
  }
57
65
  }
58
66
 
59
- // Load auto-accumulated experience (always from user scope)
60
- const exp = new AgentExperience(agentName)
61
- const experienceContent = exp.getExperience()
62
-
67
+ // Load auto-accumulated experience rules (always from user scope)
68
+ // Gated by crsi.ruleInjection feature flag
63
69
  let experienceMemory = ''
64
- if (experienceContent) {
65
- const lines = experienceContent.split('\n')
66
- const statsIdx = lines.findIndex((l) => l.startsWith('## Stats'))
67
- const successIdx = lines.findIndex((l) => l.startsWith('## Success Patterns'))
68
- const failureIdx = lines.findIndex((l) => l.startsWith('## Failure Patterns'))
69
-
70
- // Header: content before the first section
71
- const firstSection = Math.min(
72
- successIdx !== -1 ? successIdx : Infinity,
73
- failureIdx !== -1 ? failureIdx : Infinity,
74
- statsIdx !== -1 ? statsIdx : Infinity,
75
- )
76
- const header = lines.slice(0, firstSection === Infinity ? 3 : firstSection)
77
-
78
- // Last 5 success entries (summary line only, not detail)
79
- const successLines: string[] = []
80
- if (successIdx !== -1) {
81
- const endIdx = Math.min(
82
- failureIdx !== -1 && failureIdx > successIdx ? failureIdx : Infinity,
83
- statsIdx !== -1 && statsIdx > successIdx ? statsIdx : Infinity,
84
- )
85
- successLines.push(
86
- ...lines
87
- .slice(successIdx + 1, endIdx === Infinity ? undefined : endIdx)
88
- .filter((l) => l.startsWith('- ['))
89
- .slice(-5),
90
- )
91
- }
92
-
93
- // Last 3 failure entries (summary line only, not detail)
94
- const failureLines: string[] = []
95
- if (failureIdx !== -1) {
96
- const endIdx = statsIdx !== -1 && statsIdx > failureIdx ? statsIdx : Infinity
97
- failureLines.push(
98
- ...lines
99
- .slice(failureIdx + 1, endIdx === Infinity ? undefined : endIdx)
100
- .filter((l) => l.startsWith('- ['))
101
- .slice(-3),
102
- )
103
- }
104
-
105
- // Stats section (header + stat line)
106
- const stats = statsIdx !== -1 ? lines.slice(statsIdx, statsIdx + 3) : []
70
+ if (crsiRuleInjection) {
71
+ const exp = new AgentExperience(agentName)
72
+ const extractor = new ExperienceRuleExtractor()
73
+ const rules = extractor.prioritize(exp.getRules())
107
74
 
108
- if (successLines.length > 0 || failureLines.length > 0) {
109
- experienceMemory = [
110
- '## Agent Experience',
111
- ...header.filter((l) => l.trim()),
112
- ...successLines,
113
- ...failureLines,
114
- ...stats,
115
- ].join('\n')
75
+ if (rules.length > 0) {
76
+ experienceMemory = extractor.formatForInjection(rules)
116
77
  }
117
78
  }
118
79
 
@@ -139,6 +100,7 @@ export function createAgentContext(
139
100
  agentDef: AgentDefinition,
140
101
  toolRegistry: Map<string, ToolDefinition>,
141
102
  contextWindow?: number,
103
+ crsiConfig?: Partial<CrsiConfig>,
142
104
  ): AgentContextResult {
143
105
  // Create isolated context
144
106
  const context = new ContextManager({
@@ -149,7 +111,8 @@ export function createAgentContext(
149
111
  // Build system prompt with optional agent memory
150
112
  let systemPrompt = agentDef.systemPrompt
151
113
  if (agentDef.memory) {
152
- const memory = loadAgentMemory(agentDef.name, agentDef.memory)
114
+ const ruleInjection = crsiConfig?.ruleInjection !== false
115
+ const memory = loadAgentMemory(agentDef.name, agentDef.memory, ruleInjection)
153
116
  if (memory) {
154
117
  systemPrompt = `${systemPrompt}\n\n---\n\n${memory}`
155
118
  }
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { ExperienceRuleExtractor, type ExperienceRule } from './experience-rules.js'
3
4
 
4
5
  const MAX_EXPERIENCES = 20
5
6
 
@@ -46,6 +47,13 @@ export class AgentExperience {
46
47
  }
47
48
  }
48
49
 
50
+ getRules(): ExperienceRule[] {
51
+ const content = this.getExperience()
52
+ if (!content) return []
53
+ const extractor = new ExperienceRuleExtractor()
54
+ return extractor.extract(content, this.agentName)
55
+ }
56
+
49
57
  private appendToSection(section: string, entry: string, type: 'success' | 'failure'): void {
50
58
  mkdirSync(this.expDir, { recursive: true })
51
59
 
@@ -0,0 +1,149 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+
4
+ export interface RuleEffectiveness {
5
+ ruleId: string
6
+ appliedCount: number
7
+ successAfterCount: number
8
+ preRuleFailureRate: number
9
+ postRuleFailureRate: number
10
+ status: 'active' | 'degrading' | 'disabled'
11
+ createdAt: string
12
+ lastEvaluatedAt: string
13
+ evaluationHistory: Array<{
14
+ date: string
15
+ appliedCount: number
16
+ failureRate: number
17
+ }>
18
+ }
19
+
20
+ const EVAL_THRESHOLD = 10 // evaluate after 10 applications
21
+ const STORE_FILE = 'effectiveness.json'
22
+
23
+ export class EffectivenessTracker {
24
+ private data: Map<string, RuleEffectiveness>
25
+ private storePath: string
26
+
27
+ constructor(storeDir: string = join(process.env.HOME || '~', '.mipham', 'rule-engine')) {
28
+ this.storePath = join(storeDir, STORE_FILE)
29
+ this.data = new Map()
30
+ }
31
+
32
+ recordApplication(ruleId: string, success: boolean): void {
33
+ let eff = this.data.get(ruleId)
34
+ if (!eff) {
35
+ eff = {
36
+ ruleId,
37
+ appliedCount: 0,
38
+ successAfterCount: 0,
39
+ preRuleFailureRate: 1.0,
40
+ postRuleFailureRate: 0,
41
+ status: 'active',
42
+ createdAt: new Date().toISOString().slice(0, 10),
43
+ lastEvaluatedAt: '',
44
+ evaluationHistory: [],
45
+ }
46
+ this.data.set(ruleId, eff)
47
+ }
48
+
49
+ eff.appliedCount++
50
+ if (success) eff.successAfterCount++
51
+
52
+ // Calculate current failure rate
53
+ if (eff.appliedCount >= EVAL_THRESHOLD) {
54
+ eff.postRuleFailureRate = 1 - eff.successAfterCount / eff.appliedCount
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Evaluate rule effectiveness and return auto-management actions.
60
+ *
61
+ * @param autoRuleManagement — when false, skip auto-degrade/disable/enable logic
62
+ * but still record evaluation history for manual review. Default true.
63
+ */
64
+ evaluate(autoRuleManagement = true): {
65
+ upgrades: string[]
66
+ degradations: string[]
67
+ disables: string[]
68
+ } {
69
+ const result = {
70
+ upgrades: [] as string[],
71
+ degradations: [] as string[],
72
+ disables: [] as string[],
73
+ }
74
+ const now = new Date().toISOString().slice(0, 10)
75
+
76
+ for (const [ruleId, eff] of this.data) {
77
+ if (eff.appliedCount < EVAL_THRESHOLD) continue
78
+
79
+ eff.lastEvaluatedAt = now
80
+ eff.evaluationHistory.push({
81
+ date: now,
82
+ appliedCount: eff.appliedCount,
83
+ failureRate: eff.postRuleFailureRate,
84
+ })
85
+
86
+ // Keep max 10 history entries
87
+ if (eff.evaluationHistory.length > 10) {
88
+ eff.evaluationHistory = eff.evaluationHistory.slice(-10)
89
+ }
90
+
91
+ // Auto-management is gated by crsi.autoRuleManagement feature flag
92
+ if (!autoRuleManagement) continue
93
+
94
+ // Degrade: active rule with high failure rate
95
+ if (eff.status === 'active' && eff.postRuleFailureRate > 0.6) {
96
+ eff.status = 'degrading'
97
+ result.degradations.push(ruleId)
98
+ }
99
+
100
+ // Disable: degrading rule with no improvement across evaluations
101
+ if (eff.status === 'degrading' && eff.evaluationHistory.length >= 2) {
102
+ const last = eff.evaluationHistory[eff.evaluationHistory.length - 1]
103
+ const prev = eff.evaluationHistory[eff.evaluationHistory.length - 2]
104
+ if (last!.failureRate >= prev!.failureRate) {
105
+ eff.status = 'disabled'
106
+ result.disables.push(ruleId)
107
+ }
108
+ }
109
+
110
+ // Upgrade: degrading rule that has improved significantly
111
+ if (eff.status === 'degrading' && eff.postRuleFailureRate < 0.4) {
112
+ eff.status = 'active'
113
+ result.upgrades.push(ruleId)
114
+ }
115
+ }
116
+
117
+ return result
118
+ }
119
+
120
+ getEffectiveness(ruleId: string): RuleEffectiveness | null {
121
+ return this.data.get(ruleId) || null
122
+ }
123
+
124
+ persist(): void {
125
+ const dir = dirname(this.storePath)
126
+ mkdirSync(dir, { recursive: true })
127
+
128
+ const obj: Record<string, RuleEffectiveness> = {}
129
+ for (const [k, v] of this.data) {
130
+ obj[k] = v
131
+ }
132
+ writeFileSync(this.storePath, JSON.stringify(obj, null, 2), 'utf-8')
133
+ }
134
+
135
+ load(): void {
136
+ if (!existsSync(this.storePath)) return
137
+ try {
138
+ const raw = JSON.parse(readFileSync(this.storePath, 'utf-8'))
139
+ this.data = new Map(Object.entries(raw))
140
+ } catch {
141
+ // Corrupt file — start fresh
142
+ this.data = new Map()
143
+ }
144
+ }
145
+
146
+ get allRules(): RuleEffectiveness[] {
147
+ return Array.from(this.data.values())
148
+ }
149
+ }
@@ -0,0 +1,188 @@
1
+ export interface ExperienceRule {
2
+ id: string // rule-<category>-<shortHash>
3
+ type: 'mandatory' | 'warning'
4
+ condition: string
5
+ action: string
6
+ evidence: {
7
+ failureCount: number
8
+ lastFailure: string // ISO date
9
+ examples: string[]
10
+ }
11
+ category: 'timeout' | 'import' | 'search' | 'tool-params' | 'semantic'
12
+ source: 'agent-experience' | 'manual' | 'pattern-analyzer'
13
+ agentName: string
14
+ createdAt: string // ISO date
15
+ }
16
+
17
+ export interface FailureEntry {
18
+ date: string
19
+ description: string
20
+ }
21
+
22
+ export function parseFailureEntries(content: string): FailureEntry[] {
23
+ const entries: FailureEntry[] = []
24
+ const failureIdx = content.indexOf('## Failure Patterns')
25
+ if (failureIdx === -1) return entries
26
+
27
+ const afterFailure = content.slice(failureIdx)
28
+ const nextSection = afterFailure.indexOf('\n## ', '## Failure Patterns'.length)
29
+ const section = nextSection !== -1 ? afterFailure.slice(0, nextSection) : afterFailure
30
+
31
+ const lines = section.split('\n')
32
+ for (const line of lines) {
33
+ const match = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]\s+(.+)/)
34
+ if (match && match[1] && match[2]) {
35
+ entries.push({ date: match[1], description: match[2].trim() })
36
+ }
37
+ }
38
+ return entries
39
+ }
40
+
41
+ export function categorize(description: string): ExperienceRule['category'] {
42
+ const lower = description.toLowerCase()
43
+ if (lower.includes('timeout')) return 'timeout'
44
+ if (/module_not_found|import|\.js/.test(lower)) return 'import'
45
+ if (/grep|token.*overflow|search.*scope/.test(lower)) return 'search'
46
+ if (/bash|command.*fail|docker|npm|pnpm|cargo|brew/.test(lower)) return 'tool-params'
47
+ return 'semantic'
48
+ }
49
+
50
+ function createRuleId(category: string, description: string): string {
51
+ // Hash the first 30 chars of description for uniqueness
52
+ const hash = description
53
+ .slice(0, 30)
54
+ .split('')
55
+ .reduce((acc, c) => {
56
+ return ((acc << 5) - acc + c.charCodeAt(0)) | 0
57
+ }, 0)
58
+ .toString(36)
59
+ .slice(-6)
60
+ return `rule-${category}-${hash}`
61
+ }
62
+
63
+ function conditionForCategory(category: string, entries: FailureEntry[]): string {
64
+ const combined = entries.map((e) => e.description).join('; ')
65
+ switch (category) {
66
+ case 'timeout':
67
+ return 'heavy CLI commands (npm/docker/pnpm)'
68
+ case 'import':
69
+ return 'ESM module imports missing .js extension'
70
+ case 'search':
71
+ return 'full-repository Grep without directory scoping'
72
+ case 'tool-params':
73
+ return combined.slice(0, 100)
74
+ default:
75
+ return combined.slice(0, 100)
76
+ }
77
+ }
78
+
79
+ function actionForCategory(category: string): string {
80
+ switch (category) {
81
+ case 'timeout':
82
+ return 'set Bash timeout ≥ 300000ms for heavy commands'
83
+ case 'import':
84
+ return 'always append .js extension to ESM relative imports'
85
+ case 'search':
86
+ return 'use Glob to narrow directory before Grep'
87
+ case 'tool-params':
88
+ return 'review and adjust tool parameters before execution'
89
+ default:
90
+ return 'verify tool parameters match known good patterns'
91
+ }
92
+ }
93
+
94
+ export class ExperienceRuleExtractor {
95
+ extract(content: string, agentName: string): ExperienceRule[] {
96
+ if (!content || !content.includes('## Failure Patterns')) return []
97
+
98
+ const entries = parseFailureEntries(content)
99
+ if (entries.length < 2) return []
100
+
101
+ // Group by category
102
+ const byCategory = new Map<string, FailureEntry[]>()
103
+ for (const entry of entries) {
104
+ const cat = categorize(entry.description)
105
+ const existing = byCategory.get(cat) || []
106
+ existing.push(entry)
107
+ byCategory.set(cat, existing)
108
+ }
109
+
110
+ const rules: ExperienceRule[] = []
111
+ for (const [category, catEntries] of byCategory) {
112
+ if (catEntries.length < 2) continue
113
+
114
+ const type: 'mandatory' | 'warning' = catEntries.length >= 3 ? 'mandatory' : 'warning'
115
+ const firstEntry = catEntries[0]
116
+ const lastEntry = catEntries[catEntries.length - 1]
117
+ if (!firstEntry || !lastEntry) continue
118
+
119
+ rules.push({
120
+ id: createRuleId(category, firstEntry.description),
121
+ type,
122
+ condition: conditionForCategory(category, catEntries),
123
+ action: actionForCategory(category),
124
+ evidence: {
125
+ failureCount: catEntries.length,
126
+ lastFailure: lastEntry.date,
127
+ examples: catEntries.slice(0, 3).map((e) => e.description),
128
+ },
129
+ category: category as ExperienceRule['category'],
130
+ source: 'agent-experience',
131
+ agentName,
132
+ createdAt: new Date().toISOString().slice(0, 10),
133
+ })
134
+ }
135
+
136
+ return rules
137
+ }
138
+
139
+ prioritize(rules: ExperienceRule[]): ExperienceRule[] {
140
+ const order: Record<string, number> = {
141
+ mandatory: 0,
142
+ warning: 1,
143
+ }
144
+ return [...rules].sort((a, b) => {
145
+ const aOrder = order[a.type] ?? 0
146
+ const bOrder = order[b.type] ?? 0
147
+ const typeDiff = aOrder - bOrder
148
+ if (typeDiff !== 0) return typeDiff
149
+ // Within same type, more failures first
150
+ return b.evidence.failureCount - a.evidence.failureCount
151
+ })
152
+ }
153
+
154
+ formatForInjection(rules: ExperienceRule[]): string {
155
+ if (rules.length === 0) return ''
156
+
157
+ const mandatory = rules.filter((r) => r.type === 'mandatory')
158
+ const warnings = rules.filter((r) => r.type === 'warning')
159
+
160
+ let output = ''
161
+
162
+ if (mandatory.length > 0) {
163
+ output += '## ⚠️ Active Mandatory Rules (learned from past failures)\n\n'
164
+ for (const r of mandatory) {
165
+ const idx = mandatory.indexOf(r) + 1
166
+ output += `${idx}. [${r.category}] ${r.condition} → ${r.action}\n`
167
+ output += ` Evidence: ${r.evidence.failureCount} failures`
168
+ if (r.evidence.lastFailure) output += `, last: ${r.evidence.lastFailure}`
169
+ output += '\n'
170
+ if (r.evidence.examples.length > 0) {
171
+ output += ` Example: ${r.evidence.examples[0]}\n`
172
+ }
173
+ output += '\n'
174
+ }
175
+ }
176
+
177
+ if (warnings.length > 0) {
178
+ output += '## ⚡ Observed Patterns (warning level)\n\n'
179
+ for (const r of warnings) {
180
+ const idx = warnings.indexOf(r) + 1
181
+ output += `${idx}. [${r.category}] ${r.condition} → ${r.action}\n`
182
+ output += ` Evidence: ${r.evidence.failureCount} occurrences\n\n`
183
+ }
184
+ }
185
+
186
+ return output.trim()
187
+ }
188
+ }
@@ -0,0 +1,154 @@
1
+ import { readdirSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { AgentExperience } from './agent-experience.js'
5
+ import { parseFailureEntries, categorize, type ExperienceRule } from './experience-rules.js'
6
+ import type { ToolRule } from '../core/rule-engine.js'
7
+
8
+ export interface Pattern {
9
+ id: string
10
+ category: 'timeout' | 'import' | 'search' | 'tool-params' | 'semantic'
11
+ agentName: string
12
+ frequency: number
13
+ confidence: 'high' | 'medium' | 'low'
14
+ examples: string[]
15
+ firstSeen: string
16
+ lastSeen: string
17
+ }
18
+
19
+ export class PatternAnalyzer {
20
+ analyzeAgent(agentName: string, baseDir?: string): Pattern[] {
21
+ const dir = baseDir || join(homedir(), '.mipham', 'agent-memory')
22
+ const exp = new AgentExperience(agentName, dir)
23
+ const content = exp.getExperience()
24
+ if (!content) return []
25
+
26
+ const allEntries = parseFailureEntries(content)
27
+
28
+ // Group by category
29
+ const byCategory = new Map<string, { descriptions: string[]; dates: string[] }>()
30
+ for (const entry of allEntries) {
31
+ const cat = categorize(entry.description)
32
+ const existing = byCategory.get(cat) || { descriptions: [], dates: [] }
33
+ existing.descriptions.push(entry.description)
34
+ existing.dates.push(entry.date)
35
+ byCategory.set(cat, existing)
36
+ }
37
+
38
+ const patterns: Pattern[] = []
39
+ for (const [category, data] of byCategory) {
40
+ if (data.descriptions.length < 3) continue
41
+
42
+ patterns.push({
43
+ id: `pattern-${category}-${agentName}`,
44
+ category: category as Pattern['category'],
45
+ agentName,
46
+ frequency: data.descriptions.length,
47
+ confidence: data.descriptions.length >= 5 ? 'high' : 'medium',
48
+ examples: data.descriptions.slice(0, 5),
49
+ firstSeen: data.dates[0] ?? '',
50
+ lastSeen: data.dates[data.dates.length - 1] ?? '',
51
+ })
52
+ }
53
+
54
+ return patterns
55
+ }
56
+
57
+ analyzeAllAgents(baseDir?: string): Pattern[] {
58
+ const dir = baseDir || join(homedir(), '.mipham', 'agent-memory')
59
+ if (!existsSync(dir)) return []
60
+
61
+ let agents: string[]
62
+ try {
63
+ agents = readdirSync(dir, { withFileTypes: true })
64
+ .filter((d) => d.isDirectory())
65
+ .map((d) => d.name)
66
+ } catch {
67
+ return []
68
+ }
69
+
70
+ const allPatterns: Pattern[] = []
71
+ for (const agent of agents) {
72
+ allPatterns.push(...this.analyzeAgent(agent, baseDir))
73
+ }
74
+ return allPatterns
75
+ }
76
+
77
+ toRule(pattern: Pattern): ExperienceRule {
78
+ return {
79
+ id: `${pattern.id}-${Date.now().toString(36)}`,
80
+ type: pattern.frequency >= 3 ? 'mandatory' : 'warning',
81
+ condition: this._conditionForCategory(pattern),
82
+ action: this._actionForCategory(pattern.category),
83
+ evidence: {
84
+ failureCount: pattern.frequency,
85
+ lastFailure: pattern.lastSeen,
86
+ examples: pattern.examples.slice(0, 3),
87
+ },
88
+ category: pattern.category,
89
+ source: 'pattern-analyzer',
90
+ agentName: pattern.agentName,
91
+ createdAt: new Date().toISOString().slice(0, 10),
92
+ }
93
+ }
94
+
95
+ toToolRule(pattern: Pattern): ToolRule {
96
+ const toolNameMap: Record<string, string> = {
97
+ timeout: 'Bash',
98
+ 'tool-params': 'Bash',
99
+ import: 'Write',
100
+ search: 'Grep',
101
+ semantic: 'Bash',
102
+ }
103
+
104
+ return {
105
+ id: pattern.id,
106
+ toolName: toolNameMap[pattern.category] || 'Bash',
107
+ category: pattern.category,
108
+ match: (p: Record<string, unknown>): boolean => {
109
+ if (pattern.category === 'timeout') {
110
+ const cmd = String(p.command ?? '')
111
+ const heavy = /npm|docker|pnpm|cargo|brew|install|build/.test(cmd)
112
+ if (!heavy) return false
113
+ const timeout = (p as Record<string, unknown>).timeout as number | undefined
114
+ return !timeout || timeout < 300_000
115
+ }
116
+ return true
117
+ },
118
+ fix: (p: Record<string, unknown>) => ({
119
+ modified: pattern.category === 'timeout' ? { ...p, timeout: 300_000 } : p,
120
+ warning: `🤖 [auto-rule] ${pattern.category}: ${pattern.frequency} 次同类失败 — ${this._actionForCategory(pattern.category)}`,
121
+ }),
122
+ source: 'pattern-analyzer',
123
+ enabled: true,
124
+ }
125
+ }
126
+
127
+ // ── Private helpers ──
128
+
129
+ private _conditionForCategory(pattern: Pattern): string {
130
+ switch (pattern.category) {
131
+ case 'timeout':
132
+ return 'heavy CLI commands (npm/docker/pnpm/cargo)'
133
+ case 'import':
134
+ return 'ESM module imports missing .js extension'
135
+ case 'search':
136
+ return 'full-repository search without directory scoping'
137
+ default:
138
+ return pattern.examples[0]?.slice(0, 100) || 'unknown condition'
139
+ }
140
+ }
141
+
142
+ private _actionForCategory(category: string): string {
143
+ switch (category) {
144
+ case 'timeout':
145
+ return 'set Bash timeout ≥ 300000ms for heavy commands'
146
+ case 'import':
147
+ return 'append .js extension to ESM relative imports'
148
+ case 'search':
149
+ return 'use Glob to narrow directory before Grep'
150
+ default:
151
+ return 'review and adjust tool parameters before execution'
152
+ }
153
+ }
154
+ }