@miphamai/cli 0.22.0 → 0.24.0

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.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skills/workflows/audit.js +66 -18
  4. package/src/agent/agent-context.ts +20 -57
  5. package/src/agent/agent-experience.ts +8 -0
  6. package/src/agent/cross-session/discovery.ts +99 -0
  7. package/src/agent/cross-session/file-inbox.ts +118 -0
  8. package/src/agent/cross-session/transport.ts +17 -0
  9. package/src/agent/effectiveness-tracker.ts +149 -0
  10. package/src/agent/experience-rules.ts +188 -0
  11. package/src/agent/message-router.ts +83 -0
  12. package/src/agent/pattern-analyzer.ts +154 -0
  13. package/src/agent/sub-agent.ts +67 -0
  14. package/src/agent/types.ts +2 -0
  15. package/src/config/defaults.ts +12 -0
  16. package/src/config/loader.ts +33 -0
  17. package/src/core/credential-masker/env-filter.ts +39 -0
  18. package/src/core/credential-masker/index.ts +35 -0
  19. package/src/core/credential-masker/matcher.ts +106 -0
  20. package/src/core/credential-masker/output-scrub.ts +27 -0
  21. package/src/core/credential-masker/pipeline.ts +67 -0
  22. package/src/core/credential-masker/strategies/aws.ts +50 -0
  23. package/src/core/credential-masker/strategies/extract.ts +106 -0
  24. package/src/core/credential-masker/strategies/full.ts +15 -0
  25. package/src/core/credential-masker/strategies/jwt.ts +75 -0
  26. package/src/core/credential-masker/types.ts +16 -0
  27. package/src/core/credential-masker.ts +12 -187
  28. package/src/core/engine.ts +114 -10
  29. package/src/core/rule-engine.ts +179 -0
  30. package/src/core/session-store.ts +15 -2
  31. package/src/index.tsx +47 -2
  32. package/src/shared/types.ts +69 -5
  33. package/src/tools/agent/agent.ts +7 -1
  34. package/src/tools/agent/list-agents.ts +67 -0
  35. package/src/tools/agent/send-message.ts +21 -24
  36. package/src/tools/exec/bash.ts +67 -3
  37. package/src/tools/index.ts +2 -0
  38. package/src/ui/commands.ts +133 -0
package/README.md CHANGED
@@ -16,7 +16,7 @@ Requirements: **Bun 1.2+** (recommended) or **Node.js 22+**
16
16
  ## Features
17
17
 
18
18
  - **9 AI Providers** — Anthropic Claude · OpenAI GPT · DeepSeek · Kimi (Moonshot) · Google Gemini · Doubao 豆包 · Tencent Hunyuan 混元 · Qwen 通义千问 · MiphamAI
19
- - **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc), Claude Code-compatible, zero re-learning
19
+ - **66 Slash Commands** — Interactive command picker (↑↓ Enter Esc), intuitive command interface, zero re-learning
20
20
  - **16 Built-in Tools** — File ops, shell execution, agent dispatch, MCP integration, web search
21
21
  - **14 Skills + Marketplace** — 11 standard + 3 Mipham-exclusive skills. `/browse-skills` `/install-skill` community marketplace
22
22
  - **Self-Update** — `mipham update` / `/upgrade` — one command to check, backup config, upgrade, restore. API keys preserved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -10,37 +10,85 @@ export const meta = {
10
10
  }
11
11
 
12
12
  phase('Scope')
13
- const targets = args.targets || (await agent(
14
- 'List all source files that need security auditing. Return { files: [{ path, reason }] }',
15
- { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'object', properties: { path: { type: 'string' }, reason: { type: 'string' } }, required: ['path', 'reason'] } } }, required: ['files'] } },
16
- )).files
13
+ const targets =
14
+ args.targets ||
15
+ (
16
+ await agent(
17
+ 'List all source files that need security auditing. Return { files: [{ path, reason }] }',
18
+ {
19
+ schema: {
20
+ type: 'object',
21
+ properties: {
22
+ files: {
23
+ type: 'array',
24
+ items: {
25
+ type: 'object',
26
+ properties: { path: { type: 'string' }, reason: { type: 'string' } },
27
+ required: ['path', 'reason'],
28
+ },
29
+ },
30
+ },
31
+ required: ['files'],
32
+ },
33
+ },
34
+ )
35
+ ).files
17
36
 
18
37
  log(`Auditing ${targets.length} files`)
19
38
 
20
39
  phase('Audit')
21
- const raw = (await pipeline(
22
- targets,
23
- t => agent(`Security audit ${t.path}: injection, auth, crypto, secrets, input validation. Return { findings: [{ severity, file, line, summary }] }`,
24
- { label: `audit:${t.path}`, schema: { type: 'object', properties: { findings: { type: 'array', items: { type: 'object', properties: { severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, file: { type: 'string' }, line: { type: 'number' }, summary: { type: 'string' } }, required: ['severity', 'file', 'summary'] } } }, required: ['findings'] } },
25
- )),
40
+ const raw = await pipeline(targets, (t) =>
41
+ agent(
42
+ `Security audit ${t.path}: injection, auth, crypto, secrets, input validation. Return { findings: [{ severity, file, line, summary }] }`,
43
+ {
44
+ label: `audit:${t.path}`,
45
+ schema: {
46
+ type: 'object',
47
+ properties: {
48
+ findings: {
49
+ type: 'array',
50
+ items: {
51
+ type: 'object',
52
+ properties: {
53
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
54
+ file: { type: 'string' },
55
+ line: { type: 'number' },
56
+ summary: { type: 'string' },
57
+ },
58
+ required: ['severity', 'file', 'summary'],
59
+ },
60
+ },
61
+ },
62
+ required: ['findings'],
63
+ },
64
+ },
65
+ ),
26
66
  )
27
- const findings = raw.flatMap(r => (r && r.findings) || [])
67
+ const findings = raw.flatMap((r) => (r && r.findings) || [])
28
68
 
29
69
  log(`Found ${findings.length} potential issues`)
30
70
 
31
71
  phase('Verify')
32
72
  const verified = await parallel(
33
- findings.map(f => () =>
34
- verify(f, {
35
- mode: 'adversarial',
36
- skeptics: 3,
37
- threshold: 2,
38
- schema: { type: 'object', properties: { real: { type: 'boolean' }, reason: { type: 'string' } }, required: ['real', 'reason'] },
39
- })
73
+ findings.map(
74
+ (f) => () =>
75
+ verify(f, {
76
+ mode: 'adversarial',
77
+ skeptics: 3,
78
+ threshold: 2,
79
+ schema: {
80
+ type: 'object',
81
+ properties: { real: { type: 'boolean' }, reason: { type: 'string' } },
82
+ required: ['real', 'reason'],
83
+ },
84
+ }),
40
85
  ),
41
86
  )
42
87
 
43
- const confirmed = verified.filter(Boolean).filter(v => v.survives).map(v => v.finding)
88
+ const confirmed = verified
89
+ .filter(Boolean)
90
+ .filter((v) => v.survives)
91
+ .map((v) => v.finding)
44
92
  log(`${confirmed.length}/${findings.length} findings verified`)
45
93
 
46
94
  phase('Report')
@@ -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,99 @@
1
+ import {
2
+ readdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ unlinkSync,
8
+ } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir, hostname } from 'node:os'
11
+ import type { SessionInfo } from '../../shared/types'
12
+
13
+ const MIPHAM_HOME = join(homedir(), '.mipham')
14
+ const ACTIVE_SESSIONS_DIR = join(MIPHAM_HOME, '.active-sessions')
15
+
16
+ /**
17
+ * Register the current session as active.
18
+ * Writes a SessionInfo JSON file + updates mtime for heartbeat.
19
+ */
20
+ export function registerActiveSession(info: SessionInfo): void {
21
+ mkdirSync(ACTIVE_SESSIONS_DIR, { recursive: true })
22
+ const filePath = join(ACTIVE_SESSIONS_DIR, `${info.id}.json`)
23
+ writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8')
24
+ }
25
+
26
+ /**
27
+ * Update heartbeat (mtime) for an active session.
28
+ */
29
+ export function heartbeatSession(sessionId: string): void {
30
+ const filePath = join(ACTIVE_SESSIONS_DIR, `${sessionId}.json`)
31
+ if (existsSync(filePath)) {
32
+ // Touch the file by rewriting it
33
+ const raw = readFileSync(filePath, 'utf-8')
34
+ writeFileSync(filePath, raw, 'utf-8') // updates mtime
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Unregister a session (called on shutdown).
40
+ */
41
+ export function unregisterSession(sessionId: string): void {
42
+ const filePath = join(ACTIVE_SESSIONS_DIR, `${sessionId}.json`)
43
+ try {
44
+ if (existsSync(filePath)) unlinkSync(filePath)
45
+ } catch {
46
+ // Best-effort
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Discover all active sessions.
52
+ * Reads ~/.mipham/.active-sessions/ directory.
53
+ */
54
+ export function discoverSessions(): SessionInfo[] {
55
+ if (!existsSync(ACTIVE_SESSIONS_DIR)) return []
56
+
57
+ try {
58
+ const files = readdirSync(ACTIVE_SESSIONS_DIR).filter((f) => f.endsWith('.json'))
59
+
60
+ const sessions: SessionInfo[] = []
61
+ for (const file of files) {
62
+ try {
63
+ const raw = readFileSync(join(ACTIVE_SESSIONS_DIR, file), 'utf-8')
64
+ const info = JSON.parse(raw) as SessionInfo
65
+ sessions.push(info)
66
+ } catch {
67
+ // Skip corrupt files
68
+ }
69
+ }
70
+
71
+ // Sort by most recently active
72
+ sessions.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
73
+ return sessions
74
+ } catch {
75
+ return []
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Create a SessionInfo for the current process.
81
+ */
82
+ export function createSessionInfo(
83
+ sessionId: string,
84
+ name: string,
85
+ cwd?: string,
86
+ provider?: string,
87
+ model?: string,
88
+ ): SessionInfo {
89
+ return {
90
+ id: sessionId,
91
+ name,
92
+ machine: hostname(),
93
+ pid: process.pid,
94
+ startedAt: new Date().toISOString(),
95
+ cwd,
96
+ provider,
97
+ model,
98
+ }
99
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ mkdirSync,
3
+ writeFileSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ renameSync,
9
+ } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { homedir } from 'node:os'
12
+ import type { CrossSessionTransport } from './transport'
13
+ import type { AgentMessage } from '../message-bus'
14
+ import type { SessionInfo } from '../../shared/types'
15
+
16
+ const MIPHAM_HOME = join(homedir(), '.mipham')
17
+ const INBOX_DIR = join(MIPHAM_HOME, 'inbox')
18
+
19
+ /**
20
+ * Filesystem-based cross-session transport.
21
+ *
22
+ * Messages are stored as JSON files in ~/.mipham/inbox/<session-id>/.
23
+ * Each message file is named: <ISO-timestamp>-<msg-id>.json
24
+ */
25
+ export class FileInboxTransport implements CrossSessionTransport {
26
+ private ensureDir(dir: string): void {
27
+ mkdirSync(dir, { recursive: true })
28
+ }
29
+
30
+ async send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean> {
31
+ try {
32
+ const inboxDir = join(INBOX_DIR, toSessionId)
33
+ this.ensureDir(inboxDir)
34
+
35
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
36
+ const filename = `${timestamp}-${msg.id}.json`
37
+ const filePath = join(inboxDir, filename)
38
+
39
+ // Atomic write: temp file then rename
40
+ const tmpPath = filePath + '.tmp'
41
+ writeFileSync(
42
+ tmpPath,
43
+ JSON.stringify({ ...msg, _fromSession: from.id, _fromMachine: from.machine }, null, 2),
44
+ 'utf-8',
45
+ )
46
+ renameSync(tmpPath, filePath)
47
+
48
+ return true
49
+ } catch {
50
+ return false
51
+ }
52
+ }
53
+
54
+ async poll(sessionId: string): Promise<AgentMessage[]> {
55
+ const inboxDir = join(INBOX_DIR, sessionId)
56
+ if (!existsSync(inboxDir)) return []
57
+
58
+ try {
59
+ const files = readdirSync(inboxDir)
60
+ .filter((f) => f.endsWith('.json') && !f.endsWith('.tmp'))
61
+ .sort() // chronological by timestamp prefix
62
+
63
+ const messages: AgentMessage[] = []
64
+ for (const file of files) {
65
+ try {
66
+ const raw = readFileSync(join(inboxDir, file), 'utf-8')
67
+ const parsed = JSON.parse(raw) as AgentMessage & {
68
+ _fromSession?: string
69
+ _fromMachine?: string
70
+ }
71
+
72
+ // Build the message body with cross-session origin prefix
73
+ const originPrefix = parsed._fromSession
74
+ ? `[From: ${parsed._fromSession}@${parsed._fromMachine || 'unknown'}]\n\n`
75
+ : ''
76
+
77
+ messages.push({
78
+ id: parsed.id,
79
+ from: parsed.from,
80
+ to: parsed.to,
81
+ summary: parsed.summary,
82
+ message: `${originPrefix}${parsed.message}`,
83
+ timestamp: new Date(parsed.timestamp),
84
+ read: parsed.read,
85
+ type: parsed.type,
86
+ })
87
+
88
+ // Mark as read by deleting the file (read-once)
89
+ unlinkSync(join(inboxDir, file))
90
+ } catch {
91
+ // Skip corrupt files
92
+ }
93
+ }
94
+
95
+ return messages
96
+ } catch {
97
+ return []
98
+ }
99
+ }
100
+
101
+ async listSessions(): Promise<SessionInfo[]> {
102
+ return discoverSessions()
103
+ }
104
+ }
105
+
106
+ // Import discoverSessions lazily to avoid circular dependency
107
+ import { discoverSessions } from './discovery'
108
+
109
+ // ── Singleton ──
110
+
111
+ let _transport: FileInboxTransport | null = null
112
+
113
+ export function getFileInboxTransport(): FileInboxTransport {
114
+ if (!_transport) {
115
+ _transport = new FileInboxTransport()
116
+ }
117
+ return _transport
118
+ }
@@ -0,0 +1,17 @@
1
+ import type { AgentMessage } from '../message-bus'
2
+ import type { SessionInfo } from '../../shared/types'
3
+
4
+ /**
5
+ * Transport layer for cross-session messaging.
6
+ * Implementations can use filesystem, unix sockets, TCP, etc.
7
+ */
8
+ export interface CrossSessionTransport {
9
+ /** Send a message to a destination session's inbox. Returns true on success. */
10
+ send(from: SessionInfo, toSessionId: string, msg: AgentMessage): Promise<boolean>
11
+
12
+ /** Poll for unread messages addressed to the given session. */
13
+ poll(sessionId: string): Promise<AgentMessage[]>
14
+
15
+ /** List all discoverable active sessions. */
16
+ listSessions(): Promise<SessionInfo[]>
17
+ }
@@ -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
+ }