@miphamai/cli 0.82.0 → 0.84.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 (84) hide show
  1. package/package.json +1 -1
  2. package/skills/standard/mipham-code-setup.SKILL.md +30 -8
  3. package/src/agent/agent-context.ts +5 -5
  4. package/src/agent/agent-experience.ts +2 -2
  5. package/src/agent/agent-registry.ts +4 -4
  6. package/src/agent/cross-session/discovery.ts +3 -2
  7. package/src/agent/cross-session/file-inbox.ts +2 -2
  8. package/src/agent/effectiveness-tracker.ts +2 -2
  9. package/src/agent/pattern-analyzer.ts +3 -4
  10. package/src/agent/sub-agent.ts +12 -2
  11. package/src/agent/types.ts +4 -1
  12. package/src/commands/autoloop-journal.ts +2 -2
  13. package/src/commands/environment.ts +2 -1
  14. package/src/commands/loop-scaffold.ts +2 -1
  15. package/src/commands/project.ts +86 -33
  16. package/src/config/keys-manager.ts +2 -2
  17. package/src/config/loader.ts +11 -10
  18. package/src/config/preferences.ts +3 -4
  19. package/src/core/auto-memory.ts +2 -3
  20. package/src/core/constitution-loader.ts +3 -4
  21. package/src/core/crsi-producer.ts +3 -3
  22. package/src/core/crsi-sandbox.ts +3 -2
  23. package/src/core/dream-engine.ts +2 -2
  24. package/src/core/engine.ts +38 -6
  25. package/src/core/error-signature-db.ts +2 -2
  26. package/src/core/eval-harness.ts +121 -4
  27. package/src/core/improvement-track.ts +35 -6
  28. package/src/core/instructions.ts +28 -4
  29. package/src/core/memory/memory-loader.ts +2 -3
  30. package/src/core/paths.ts +21 -1
  31. package/src/core/permission-audit.ts +120 -0
  32. package/src/core/permission-classifier.ts +449 -0
  33. package/src/core/permission-config.ts +106 -15
  34. package/src/core/permission.ts +369 -16
  35. package/src/core/rule-engine.ts +2 -2
  36. package/src/core/rules-loader.ts +3 -2
  37. package/src/core/session-log.ts +2 -3
  38. package/src/core/session-store.ts +2 -3
  39. package/src/core/task-performance.ts +22 -1
  40. package/src/core/workspace-trust.ts +4 -3
  41. package/src/daemon/database.ts +2 -2
  42. package/src/daemon/index.ts +2 -3
  43. package/src/daemon/launch.ts +3 -3
  44. package/src/daemon/server.ts +15 -0
  45. package/src/i18n-core/locales/en-US.json +3 -0
  46. package/src/i18n-core/locales/zh-CN.json +3 -0
  47. package/src/index.tsx +30 -6
  48. package/src/mcp/token-store.ts +2 -2
  49. package/src/plugin/plugin-manager.ts +2 -2
  50. package/src/shared/constants.ts +0 -1
  51. package/src/shared/package-info.ts +1 -1
  52. package/src/shared/types.ts +46 -6
  53. package/src/shared/update.ts +2 -3
  54. package/src/skills/bundled-skills.ts +1 -1
  55. package/src/skills/loader.ts +2 -3
  56. package/src/skills/marketplace.ts +2 -3
  57. package/src/skills/registry.ts +2 -2
  58. package/src/skills/skill-assets.ts +2 -2
  59. package/src/skills/usage.ts +2 -2
  60. package/src/telemetry/consent.ts +2 -3
  61. package/src/tools/agent/enter-plan.ts +3 -2
  62. package/src/tools/agent/exit-plan.ts +1 -1
  63. package/src/tools/agent/list-agents.ts +1 -1
  64. package/src/tools/agent/memory.ts +3 -3
  65. package/src/tools/agent/plan.ts +3 -2
  66. package/src/tools/agent/report-findings.ts +1 -1
  67. package/src/tools/agent/send-message.ts +1 -1
  68. package/src/tools/agent/skill.ts +1 -1
  69. package/src/tools/exec/git.ts +2 -2
  70. package/src/tools/exec/task.ts +1 -1
  71. package/src/tools/file/glob.ts +1 -1
  72. package/src/tools/file/grep.ts +1 -1
  73. package/src/tools/file/read.ts +1 -1
  74. package/src/tools/network/web-fetch.ts +1 -1
  75. package/src/tools/network/web-search.ts +1 -1
  76. package/src/tools/scheduling/cron.ts +5 -5
  77. package/src/tools/scheduling/schedule-wakeup.ts +1 -1
  78. package/src/tools/system/config.ts +2 -2
  79. package/src/tools/system/tool-search.ts +1 -1
  80. package/src/ui/app.tsx +51 -9
  81. package/src/ui/commands.ts +41 -19
  82. package/src/ui/config-wizard.tsx +2 -2
  83. package/src/ui/input.tsx +15 -3
  84. package/src/workflow/journal.ts +2 -2
@@ -6,11 +6,10 @@
6
6
  * NOT for secrets — this file is plain JSON, not encrypted.
7
7
  */
8
8
  import { readFileSync, existsSync, mkdirSync } from 'node:fs'
9
- import { join } from 'node:path'
10
- import { homedir } from 'node:os'
11
9
  import { atomicWriteFileSync } from '../shared/atomic-write'
10
+ import { miphamHome } from '../core/paths.ts'
12
11
 
13
- const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
12
+ const PREFS_PATH = miphamHome('preferences.json')
14
13
 
15
14
  function readPrefs(): Record<string, string> {
16
15
  try {
@@ -26,7 +25,7 @@ function readPrefs(): Record<string, string> {
26
25
 
27
26
  function writePrefs(prefs: Record<string, string>): void {
28
27
  try {
29
- const dir = join(homedir(), '.mipham')
28
+ const dir = miphamHome()
30
29
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
31
30
  // 原子写:裸 writeFileSync 原地截断,崩在写中途就留下一份不可解析的文件,
32
31
  // 而 readPrefs 把不可解析吞成「空」⇒ **全部**偏好静默消失(不是丢一项)。
@@ -18,8 +18,7 @@ import type { EffectivenessTracker } from '../agent/effectiveness-tracker.js'
18
18
  import type { ErrorSignatureDB } from './error-signature-db.js'
19
19
  import type { CrsiProvenanceBridge } from '../agent/crsi-provenance-bridge.js'
20
20
  import { getMetrics } from './metrics'
21
- import { join } from 'node:path'
22
- import { homedir } from 'node:os'
21
+ import { miphamHome } from './paths.ts'
23
22
 
24
23
  // ── Types ──
25
24
 
@@ -74,7 +73,7 @@ export interface TurnReflection {
74
73
 
75
74
  // ── Constants ──
76
75
 
77
- const DEFAULT_MEMORY_DIR = join(homedir(), '.mipham', 'memory')
76
+ const DEFAULT_MEMORY_DIR = miphamHome('memory')
78
77
 
79
78
  /** Minimum number of similar failures before a CRSI rule is generated. */
80
79
  const CRSI_RULE_THRESHOLD = 2
@@ -14,8 +14,7 @@
14
14
  */
15
15
 
16
16
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
17
- import { join } from 'node:path'
18
- import { homedir } from 'node:os'
17
+ import { miphamHome } from './paths.ts'
19
18
  import alignmentVocabulary from './alignment-vocabulary.json' with { type: 'json' }
20
19
 
21
20
  // ── Types ──
@@ -76,7 +75,7 @@ export class ConstitutionLoader {
76
75
  private cached: MiphamConstitution | null = null
77
76
 
78
77
  constructor(customPath?: string) {
79
- this.path = customPath || join(homedir(), '.mipham', 'ai-guardrails.yml')
78
+ this.path = customPath || miphamHome('ai-guardrails.yml')
80
79
  }
81
80
 
82
81
  /**
@@ -102,7 +101,7 @@ export class ConstitutionLoader {
102
101
  // Write the default constitution to disk for visibility
103
102
  this.cached = DEFAULT_CONSTITUTION
104
103
  try {
105
- const dir = join(homedir(), '.mipham')
104
+ const dir = miphamHome()
106
105
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
107
106
  writeFileSync(this.path, this.serializeToYaml(DEFAULT_CONSTITUTION), 'utf-8')
108
107
  } catch {
@@ -15,7 +15,7 @@ import type { MetaRule } from './meta-rule-engine'
15
15
  import type { Llm } from '../providers/llm'
16
16
  import { readdirSync, appendFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
- import { homedir } from 'node:os'
18
+ import { miphamHome } from './paths.ts'
19
19
 
20
20
  /** 教训文件(相对仓库根)。预建,沙箱只能改已存在文件。 */
21
21
  export const LESSONS_FILE = 'apps/cli/crsi-lessons.md'
@@ -514,7 +514,7 @@ export interface ProseProposalRecord {
514
514
  }
515
515
 
516
516
  function proseLedgerFile(): string {
517
- return join(homedir(), '.mipham', 'crsi', 'prose-proposals.jsonl')
517
+ return miphamHome('crsi', 'prose-proposals.jsonl')
518
518
  }
519
519
 
520
520
  /** 该信号是否已生成过散文提议。 */
@@ -537,7 +537,7 @@ export function hasProposedProse(id: string): boolean {
537
537
  /** 追加一条散文提议记录(append-only,非关键——失败不影响提议本身)。 */
538
538
  export function appendProseProposal(record: ProseProposalRecord): void {
539
539
  try {
540
- mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
540
+ mkdirSync(miphamHome('crsi'), { recursive: true })
541
541
  appendFileSync(proseLedgerFile(), JSON.stringify(record) + '\n', 'utf-8')
542
542
  } catch {
543
543
  // ledger 非关键,失败不影响提议本身
@@ -17,9 +17,10 @@
17
17
  import { execSync } from 'node:child_process'
18
18
  import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
19
  import { join, resolve, sep, posix } from 'node:path'
20
- import { tmpdir, homedir } from 'node:os'
20
+ import { tmpdir } from 'node:os'
21
21
  import { randomUUID } from 'node:crypto'
22
22
  import { LESSONS_FILE, MANAGED_RULES_FILE } from './crsi-producer'
23
+ import { miphamHome } from './paths.ts'
23
24
 
24
25
  // ── Types ──
25
26
 
@@ -86,7 +87,7 @@ export interface CrsiSessionReport {
86
87
 
87
88
  const WORKTREE_PREFIX = 'crsi-sandbox-'
88
89
  const TEST_TIMEOUT_MS = 120_000 // 2 minutes
89
- const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
90
+ const REPORT_DIR = miphamHome('crsi-sandbox')
90
91
 
91
92
  /**
92
93
  * 自改进的「不可变基础」(immutable base)——按语义角色三类。
@@ -35,7 +35,7 @@ import {
35
35
  statSync,
36
36
  } from 'node:fs'
37
37
  import { join } from 'node:path'
38
- import { homedir } from 'node:os'
38
+ import { miphamHome } from './paths.ts'
39
39
 
40
40
  // ── Types ──
41
41
 
@@ -78,7 +78,7 @@ interface MemoryFile {
78
78
 
79
79
  // ── Constants ──
80
80
 
81
- const DEFAULT_MEMORY_DIR = join(homedir(), '.mipham', 'memory')
81
+ const DEFAULT_MEMORY_DIR = miphamHome('memory')
82
82
  const STALE_DAYS = 30
83
83
  const SIMILARITY_THRESHOLD = 0.65
84
84
 
@@ -11,6 +11,7 @@ import type { ChatRequest } from '../providers/registry'
11
11
  import type { Llm } from '../providers/llm'
12
12
  import { ContextManager } from './context'
13
13
  import { PermissionSystem } from './permission'
14
+ import type { ApprovalDecision } from './permission'
14
15
  import type { HookEngine } from './hooks'
15
16
  import type { ArtifactServer } from '../artifacts/server'
16
17
  import type { AgentRegistry } from '../agent/agent-registry'
@@ -701,7 +702,7 @@ export class QueryEngine {
701
702
  const toolCallRecords: ToolCallRecord[] = []
702
703
  for (const toolUse of toolUses) {
703
704
  const toolStart = Date.now()
704
- const result = await this.executeTool(toolUse.name, toolUse.input)
705
+ const result = await this.executeTool(toolUse.name, toolUse.input, signal)
705
706
  yield {
706
707
  type: 'tool_result',
707
708
  tool_use_id: toolUse.id,
@@ -1041,7 +1042,7 @@ export class QueryEngine {
1041
1042
 
1042
1043
  // Execute tools and feed results back to the model for the next turn
1043
1044
  for (const toolUse of toolUses) {
1044
- const result = await this.executeTool(toolUse.name, toolUse.input)
1045
+ const result = await this.executeTool(toolUse.name, toolUse.input, signal)
1045
1046
  lastActivity = Date.now()
1046
1047
  yield {
1047
1048
  type: 'tool_result',
@@ -1068,7 +1069,11 @@ export class QueryEngine {
1068
1069
  // Max turns reached — safety limit, stop gracefully
1069
1070
  }
1070
1071
 
1071
- private async executeTool(name: string, params: Record<string, unknown>): Promise<ToolResult> {
1072
+ private async executeTool(
1073
+ name: string,
1074
+ params: Record<string, unknown>,
1075
+ signal?: AbortSignal,
1076
+ ): Promise<ToolResult> {
1072
1077
  getMetrics().toolCalls.inc({ tool_name: name })
1073
1078
  const tool = this.tools.get(name)
1074
1079
  if (!tool) {
@@ -1080,12 +1085,20 @@ export class QueryEngine {
1080
1085
  return { success: false, content: '', error: `Unknown tool: ${name}${hint}` }
1081
1086
  }
1082
1087
 
1083
- // Security: check permission before executing
1084
- if (this.permission.needsApproval(tool, params)) {
1088
+ // Security: check permission before executing.
1089
+ //
1090
+ // `resolveApproval` answers the same question `needsApproval` did for every mode
1091
+ // except `auto`, where it additionally lets the classifier rule on a call the
1092
+ // static chain could only refuse. It is deliberately a superset — the earlier
1093
+ // `needsApproval(...)` check would have been a *second* gate, and a second gate
1094
+ // is how a call refused here gets allowed there. The signal is the caller's, so
1095
+ // an interrupt cancels a ruling in flight (the classifier denies on abort).
1096
+ const decision = await this.permission.resolveApproval(tool, params, { signal })
1097
+ if (decision.level === 'ask') {
1085
1098
  // P1-4: Increment consecutive block counter; if limit exceeded,
1086
1099
  // tell the model to move on instead of retrying.
1087
1100
  const limitExceeded = this.permission.incrementBlockCounter()
1088
- const baseError = this.buildDenialError(name, tool, params)
1101
+ const baseError = this.buildDenialError(name, tool, params, decision)
1089
1102
  const moveOnHint = limitExceeded
1090
1103
  ? '\n(Consecutive block limit reached. Please try a different approach or ask the user for guidance.)'
1091
1104
  : ''
@@ -1451,12 +1464,31 @@ export class QueryEngine {
1451
1464
  /**
1452
1465
  * Build a rich permission-denial error naming the mode (level), the setting
1453
1466
  * (rule/level) that caused the denial, and the correct fix (#52).
1467
+ *
1468
+ * The `decision` comes from `resolveApproval` and is used rather than re-derived:
1469
+ * `explainDenial` can only describe the *static* chain, so for a classifier
1470
+ * refusal it would report the underlying `mode-baseline`/`tool-default` and tell
1471
+ * the model to switch modes — advice that changes nothing, because the refusal is
1472
+ * the classifier's, not the mode's. The parameter is optional so the static path
1473
+ * keeps working unchanged where no decision is at hand.
1454
1474
  */
1455
1475
  private buildDenialError(
1456
1476
  name: string,
1457
1477
  tool: ToolDefinition,
1458
1478
  params: Record<string, unknown>,
1479
+ decision?: ApprovalDecision,
1459
1480
  ): string {
1481
+ if (decision?.source === 'classifier' && decision.denialReason === 'classifier-deny') {
1482
+ // Two different facts, and the model acts differently on each: a policy
1483
+ // refusal is final, while an unreachable/unreadable classifier means the call
1484
+ // was *held back* and a retry is appropriate. Telling it "denied" for the
1485
+ // second makes it abandon work that was never actually judged.
1486
+ const reason = decision.classifierReason ?? ''
1487
+ return decision.retryable
1488
+ ? t('errors.tool_denied_classifier_unavailable', { name, reason })
1489
+ : t('errors.tool_denied_classifier', { name, reason })
1490
+ }
1491
+
1460
1492
  const { reason, rulePattern } = this.permission.explainDenial(tool, params)
1461
1493
  const mode = this.permission.getMode()
1462
1494
  switch (reason) {
@@ -16,8 +16,8 @@
16
16
 
17
17
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
18
18
  import { join } from 'node:path'
19
- import { homedir } from 'node:os'
20
19
  import { randomUUID } from 'node:crypto'
20
+ import { miphamHome } from './paths.ts'
21
21
 
22
22
  // ── Types ──
23
23
 
@@ -61,7 +61,7 @@ export interface ErrorSignatureStats {
61
61
 
62
62
  // ── Constants ──
63
63
 
64
- const DEFAULT_STORE_DIR = join(homedir(), '.mipham', 'sis')
64
+ const DEFAULT_STORE_DIR = miphamHome('sis')
65
65
  const STORE_FILE = 'error-signatures.json'
66
66
  const MIN_SUCCESS_RATE = 0.5 // below this → degraded
67
67
  const RETIREMENT_RATE = 0.2 // below this and > 90 days old → retired
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { join } from 'node:path'
15
- import { tmpdir, homedir } from 'node:os'
15
+ import { tmpdir } from 'node:os'
16
16
  import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'node:fs'
17
17
  import { ExperienceRuleEngine } from './rule-engine'
18
18
  import { ConstitutionLoader, DEFAULT_CONSTITUTION } from './constitution-loader'
@@ -37,6 +37,7 @@ import {
37
37
  import type { CrsiSignal } from './crsi-producer'
38
38
  import { predictionHit } from './improvement-track'
39
39
  import { loadBehaviorTasks, judgeBehaviorTask } from './behavior-tasks'
40
+ import { miphamHome } from './paths.ts'
40
41
 
41
42
  // ── Types ──
42
43
 
@@ -95,15 +96,29 @@ export function regressedAnchors(results: EvalResult[]): string[] {
95
96
 
96
97
  // ── Rewards log (path A Phase 1: 奖励信号持久化) ──
97
98
 
98
- const SCORES_FILE = join(homedir(), '.mipham', 'crsi', 'eval-scores.jsonl')
99
+ const SCORES_FILE = miphamHome('crsi', 'eval-scores.jsonl')
100
+
101
+ /** 落盘的契约粒度投影 —— 只要 id/passed/role(EvalResult 的 description/detail 不落盘)。 */
102
+ export interface ContractResultRecord {
103
+ id: string
104
+ passed: boolean
105
+ role?: ContractRole
106
+ }
107
+
108
+ /** 一次评估的契约粒度快照:契约 id → 是否通过。 */
109
+ export type ContractSnapshot = Record<string, boolean>
110
+
111
+ function toContractResultRecord(r: EvalResult): ContractResultRecord {
112
+ return { id: r.id, passed: r.passed, ...(r.role ? { role: r.role } : {}) }
113
+ }
99
114
 
100
115
  /** 追加一次评估分数到 rewards 日志(按奖励函数名键控)。 */
101
116
  export function appendEvalScore(
102
117
  name: string,
103
- report: { score: number; passed: number; total: number },
118
+ report: { score: number; passed: number; total: number; results?: EvalResult[] },
104
119
  ): void {
105
120
  try {
106
- mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
121
+ mkdirSync(miphamHome('crsi'), { recursive: true })
107
122
  appendFileSync(
108
123
  SCORES_FILE,
109
124
  JSON.stringify({
@@ -112,6 +127,9 @@ export function appendEvalScore(
112
127
  score: report.score,
113
128
  passed: report.passed,
114
129
  total: report.total,
130
+ // 契约粒度(B1)。缺省不写该键:B1 之前落盘的旧记录没有它,
131
+ // 读取侧跳过 —— 这是向后兼容的承重判据,别改成 `results: []`。
132
+ ...(report.results ? { results: report.results.map(toContractResultRecord) } : {}),
115
133
  }) + '\n',
116
134
  'utf-8',
117
135
  )
@@ -135,6 +153,105 @@ export function getLastEvalScore(name: string): number | null {
135
153
  }
136
154
  }
137
155
 
156
+ /**
157
+ * 某奖励函数最近 n 次**按契约粒度**落盘的记录,新→旧。
158
+ *
159
+ * 只认带 `results` 的记录 —— B1 之前落盘的旧记录(只有聚合分数)被跳过,
160
+ * 于是调用方不必区分新旧形态。逐行容错:坏行跳过而不是让整条历史归零
161
+ * (`fixCache` 负责清理坏行)。
162
+ */
163
+ export function getContractHistory(name: string, n = 3): ContractSnapshot[] {
164
+ try {
165
+ if (!existsSync(SCORES_FILE)) return []
166
+ const lines = readFileSync(SCORES_FILE, 'utf-8').trim().split('\n').filter(Boolean)
167
+ const out: ContractSnapshot[] = []
168
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
169
+ let rec: { name?: string; results?: ContractResultRecord[] }
170
+ try {
171
+ rec = JSON.parse(lines[i]!) as { name?: string; results?: ContractResultRecord[] }
172
+ } catch {
173
+ continue
174
+ }
175
+ if (rec.name !== name || !Array.isArray(rec.results)) continue
176
+ const snap: ContractSnapshot = {}
177
+ for (const r of rec.results) {
178
+ if (typeof r?.id === 'string') snap[r.id] = r.passed === true
179
+ }
180
+ out.push(snap)
181
+ }
182
+ return out
183
+ } catch {
184
+ return []
185
+ }
186
+ }
187
+
188
+ export type ContractDelta = 'regressed' | 'fixed' | 'flaky' | 'new' | 'gone'
189
+
190
+ /**
191
+ * 纯函数:当前 run vs 历史 → 每条契约的变化。**只报变化**,未变化的契约不出现。
192
+ *
193
+ * delta 判据(`history` 新→旧):
194
+ * - 历史上没出现过 → `new`
195
+ * - 历史上 true/false 都出现过 → `flaky`(压过 regressed/fixed:抖动的契约
196
+ * 不该被报成「已修复」或「回归」)
197
+ * - 上次与本次相反 → `regressed`(上次 PASS→本次 FAIL)/ `fixed`
198
+ * - 本次没有但历史有 → `gone`
199
+ *
200
+ * 为什么 `flaky` 要压过相邻两次的比较:只比相邻两次会把一个每次都在翻的契约
201
+ * 误报成「真回归」,而那正是这个账本要区分开的东西。
202
+ */
203
+ export function diffContractHistory(
204
+ current: ContractResultRecord[],
205
+ history: ContractSnapshot[],
206
+ ): { id: string; delta: ContractDelta; role?: ContractRole }[] {
207
+ const out: { id: string; delta: ContractDelta; role?: ContractRole }[] = []
208
+ const seen = new Set<string>()
209
+ for (const c of current) {
210
+ seen.add(c.id)
211
+ const past = history.filter((h) => c.id in h).map((h) => h[c.id] === true)
212
+ let delta: ContractDelta
213
+ if (past.length === 0) delta = 'new'
214
+ else if (past.includes(true) && past.includes(false)) delta = 'flaky'
215
+ else if (past[0] === !c.passed) delta = c.passed ? 'fixed' : 'regressed'
216
+ else continue // 未变化
217
+ out.push({ id: c.id, delta, ...(c.role ? { role: c.role } : {}) })
218
+ }
219
+ for (const h of history) {
220
+ for (const id of Object.keys(h)) {
221
+ if (seen.has(id)) continue
222
+ seen.add(id)
223
+ out.push({ id, delta: 'gone' })
224
+ }
225
+ }
226
+ return out
227
+ }
228
+
229
+ /**
230
+ * 把 diffContractHistory 的结果渲染成展示行。纯函数——不做 I/O、不读时钟。
231
+ * 返回空数组表示「无变化」,调用方据此决定是否打印标题。
232
+ */
233
+ export function renderContractDiff(
234
+ deltas: { id: string; delta: ContractDelta; role?: ContractRole }[],
235
+ ): string[] {
236
+ const text: Record<ContractDelta, string> = {
237
+ regressed: '上次 PASS,本次 FAIL(回归)',
238
+ fixed: '上次 FAIL,本次 PASS(已修复)',
239
+ flaky: '近几次结果不一致(抖动)',
240
+ new: '本次新增的契约',
241
+ gone: '本次未出现(已移出契约集)',
242
+ }
243
+ const icon: Record<ContractDelta, string> = {
244
+ regressed: '❌',
245
+ fixed: '✅',
246
+ flaky: '⚠️',
247
+ new: '🆕',
248
+ gone: '➖',
249
+ }
250
+ return deltas.map(
251
+ (d) => `${icon[d.delta]} ${d.id} ← ${text[d.delta]}${d.role ? ` \`${d.role}\`` : ''}`,
252
+ )
253
+ }
254
+
138
255
  // ── Harness ──
139
256
 
140
257
  /** 构建隔离组件,避免读用户 ~/.mipham 运行时状态。 */
@@ -1,10 +1,9 @@
1
1
  // CRSI 改进轨:噪声自适应改进判定 + 台账 + pending verdict 闸。
2
2
  // A1 不破:verdict / minEffect / 改进率全是确定性算术(均值/标准差/阈值/Wilson),无 LLM 裁判。
3
3
  import { readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
4
- import { join } from 'node:path'
5
- import { homedir } from 'node:os'
6
4
  import { atomicWriteFileSync } from '../shared/atomic-write'
7
5
  import type { SkillDeltaSample } from './task-performance'
6
+ import { miphamHome } from './paths.ts'
8
7
 
9
8
  export type ImprovementVerdict = 'improved' | 'regressed' | 'inconclusive'
10
9
 
@@ -26,6 +25,13 @@ export interface ImprovementReport {
26
25
  predictedDelta?: number
27
26
  /** 预测是否命中。与 predictedDelta 同时出现、同时缺席(JSON 序列化会丢掉 undefined 键)。 */
28
27
  predictionHit?: boolean
28
+ /**
29
+ * B2 代价维:与分数数组逐项对齐的前/后耗时。**只记录,不进任何门禁** ——
30
+ * `verdict` / `deltaMean` / `minEffect` 一律不看这两个字段。
31
+ * 缺席(而非空数组)= 该记录早于代价维落地,或该样本未记代价。
32
+ */
33
+ baselineDurations?: number[]
34
+ postDurations?: number[]
29
35
  }
30
36
 
31
37
  export interface ImprovementRecord extends ImprovementReport {
@@ -83,6 +89,11 @@ export function buildImprovementReport(
83
89
  ...(predicted !== undefined
84
90
  ? { predictedDelta: predicted, predictionHit: predictionHit(predicted, deltaMean) }
85
91
  : {}),
92
+ // 代价维:**两条同生同灭**,与上面 ε 那条同理 —— 只写一半(有 baselineDurations
93
+ // 而无 postDurations)会让「代价」这件事在记录里既非有也非无,读侧无从判断。
94
+ ...(sample.baselineDurations !== undefined && sample.postDurations !== undefined
95
+ ? { baselineDurations: sample.baselineDurations, postDurations: sample.postDurations }
96
+ : {}),
86
97
  }
87
98
  }
88
99
 
@@ -148,15 +159,33 @@ export function predictionHitRate(records: ImprovementRecord[]): {
148
159
  return { total, hits, rate: total === 0 ? 0 : hits / total, lo, hi }
149
160
  }
150
161
 
162
+ /**
163
+ * 代价维的只读展示(B2):前/后均值一行。两个耗时数组缺席 → null,调用方据此整行不打印。
164
+ *
165
+ * **刻意不下结论**:倍数只是描述,不是判据。把「代价过高」变成 verdict 的一部分需要
166
+ * 样本量支撑,而当前 k 默认 3、`minEffect` 已经要 `max(20, 2×噪声)` —— 再塞一个维度
167
+ * 只会把统计问题变得更糟。所以这里只回答「花了多少」,不回答「值不值」。
168
+ *
169
+ * 基线均值为 0 时**不给倍数**:那个比值是 ∞(或 0/0 的 NaN),打出来是假读数。
170
+ */
171
+ export function formatCostLine(report: ImprovementReport): string | null {
172
+ const { baselineDurations: before_, postDurations: after_ } = report
173
+ if (before_ === undefined || after_ === undefined) return null
174
+ const before = Math.round(mean(before_))
175
+ const after = Math.round(mean(after_))
176
+ const line = `⏱️ 代价: 均值 ${before}ms → ${after}ms`
177
+ return before > 0 ? `${line}(×${(after / before).toFixed(1)})` : line
178
+ }
179
+
151
180
  // ── 台账 ──
152
181
 
153
182
  export function improvementPath(): string {
154
- return join(homedir(), '.mipham', 'crsi', 'improvements.jsonl')
183
+ return miphamHome('crsi', 'improvements.jsonl')
155
184
  }
156
185
 
157
186
  export function appendImprovement(record: ImprovementRecord): void {
158
187
  const file = improvementPath()
159
- mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
188
+ mkdirSync(miphamHome('crsi'), { recursive: true })
160
189
  // 原子激活(④):整账本读-改-写 + temp 文件 rename(见 shared/atomic-write),读者要么见旧要么见新。
161
190
  // 非原子的 appendFileSync 写中途崩溃会留撕裂行,readImprovements 会 JSON.parse 抛错。
162
191
  const existing = readImprovements()
@@ -184,7 +213,7 @@ export function readImprovements(): ImprovementRecord[] {
184
213
  // (temp+rename,见 shared/atomic-write),替代易失内存变量(进程重启即丢、无 manifest)。
185
214
 
186
215
  export function pendingVerdictPath(): string {
187
- return join(homedir(), '.mipham', 'crsi', 'pending-verdict.json')
216
+ return miphamHome('crsi', 'pending-verdict.json')
188
217
  }
189
218
 
190
219
  export function setPendingVerdict(v: ImprovementVerdict | null): void {
@@ -193,7 +222,7 @@ export function setPendingVerdict(v: ImprovementVerdict | null): void {
193
222
  rmSync(file, { force: true }) // 原子清除(unlink 原子)
194
223
  return
195
224
  }
196
- mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
225
+ mkdirSync(miphamHome('crsi'), { recursive: true })
197
226
  atomicWriteFileSync(file, JSON.stringify({ verdict: v, timestamp: new Date().toISOString() }))
198
227
  }
199
228
 
@@ -1,6 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'node:fs'
2
2
  import { join, resolve, relative, sep, isAbsolute } from 'node:path'
3
- import { homedir } from 'node:os'
4
3
  import { execSync } from 'node:child_process'
5
4
  import { parse as parseYaml } from 'yaml'
6
5
  import type { InstructionFile } from '../shared/index.ts'
@@ -11,6 +10,7 @@ import {
11
10
  buildCrsiLessonsBlock,
12
11
  type CrsiLessonSummary,
13
12
  } from './crsi-producer'
13
+ import { miphamHome } from './paths.ts'
14
14
 
15
15
  export interface FrontmatterResult {
16
16
  data: Record<string, unknown>
@@ -127,8 +127,7 @@ export class InstructionsLoader {
127
127
  })
128
128
 
129
129
  // Tier 3: 用户层 ~/.mipham/USER.md
130
- const home = homedir()
131
- this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
130
+ this.tryLoad(miphamHome('USER.md'), 'user')
132
131
 
133
132
  // CRSI 教训召回:读 crsi-lessons.md 提取精华,注入系统提示(只写不读 → 写后召回)
134
133
  this.crsiLessonSummaries = this.loadCrsiLessons(root)
@@ -327,12 +326,21 @@ Never omit it or present the work as purely human-authored.`)
327
326
  * Tells the model its current permission level and what to expect.
328
327
  */
329
328
  private buildPermissionContext(mode: string): string {
329
+ // Hand-written map, and a missing key is **silent**: the `if (!description)`
330
+ // below returns `''`, so the system prompt would simply say nothing about
331
+ // permissions rather than warn. Every `PermissionMode` member needs a line.
332
+ // `auto`'s text has to describe a gate the model cannot see: it is told
333
+ // "a classifier rules on each of your calls" rather than "you are
334
+ // unrestricted", because a model that believes it has blanket permission
335
+ // stops explaining what it is about to do — which is exactly the input the
336
+ // classifier needs.
330
337
  const modeDescriptions: Record<string, string> = {
331
338
  default:
332
339
  'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
333
340
  acceptEdits:
334
341
  'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
335
342
  plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
343
+ auto: 'You are in **auto** mode. A classifier reviews each tool call before it runs and blocks calls that are destructive, that act on instructions found in files or tool output, or that touch credentials. Approved calls run; blocked ones return a denial with the reason. Prefer explaining the intent of a call when it is unusual.',
336
344
  bypassPermissions:
337
345
  'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
338
346
  }
@@ -340,7 +348,23 @@ Never omit it or present the work as purely human-authored.`)
340
348
  const description = modeDescriptions[mode]
341
349
  if (!description) return ''
342
350
 
343
- return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode. If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch to bypassPermissions (Shift+Tab) or add an allow rule (/permissions), then wait for the user's answer.`
351
+ // What actually lifts a denial is not the same in `auto`. There the refusal is a
352
+ // ruling on one exact call, and a repeat of that same call is answered from the
353
+ // classifier cache rather than re-judged — so "retry after explaining yourself" is
354
+ // advice that cannot work, and telling the model to switch modes is advice that is
355
+ // never needed. The two levers that do work are named instead.
356
+ const escape =
357
+ mode === 'auto'
358
+ ? ' In **auto** mode the refusal is a ruling on that exact call: an allow rule (`/permissions allow`) lifts it, and so does changing the call so it no longer trips the rule — repeating the identical call returns the same ruling.'
359
+ : ''
360
+
361
+ // The tail used to name `bypassPermissions` as the Shift+Tab destination. That
362
+ // was true while the wheel carried it and became false the moment `auto`
363
+ // replaced it — and this string is *advice the model repeats to the user*, so
364
+ // staying stale makes it promise a keypress that does nothing. `bypassPermissions`
365
+ // is still reachable, but only by naming it in config; saying so is what keeps
366
+ // the model from offering it as a way out.
367
+ return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry it or any other approval-gated tool — Bash, WebSearch, network, and Workflow are all blocked in this mode.${escape} If the task genuinely needs a blocked tool, STOP retrying and ask the user to switch modes with Shift+Tab or add an allow rule (/permissions), then wait for the user's answer. Note that Shift+Tab's wheel does not reach bypassPermissions — that mode is set in config, so do not offer it as a keypress.`
344
368
  }
345
369
 
346
370
  list(): InstructionFile[] {
@@ -1,9 +1,8 @@
1
- import { join } from 'node:path'
2
- import { homedir } from 'node:os'
3
1
  import { MemoryManager } from './memory-manager'
4
2
  import type { MemoryManager as MemoryManagerType } from './memory-manager'
3
+ import { miphamHome } from '../paths.ts'
5
4
 
6
- const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
5
+ const MEMORY_DIR = miphamHome('memory')
7
6
 
8
7
  let instance: MemoryManagerType | null = null
9
8
 
package/src/core/paths.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  /**
2
- * 项目内数据目录的路径单一真源。
2
+ * 数据目录的路径单一真源 —— **项目内** `.mipham/` 与**用户级** `~/.mipham`。
3
+ *
4
+ * 目录名 `.mipham` 曾在 `src/` 里散落成几十处各自独立的字面量:用户级写
5
+ * `join(homedir(), '.mipham', …)`、项目内写 `join(cwd, '.mipham', …)`,两级的根
6
+ * 因此都没人守。现在字面量只剩 `shared/constants.ts` 的 `MIPHAM_DIR` 一处 ——
7
+ * 用户级一律经 `miphamHome()`,项目内一律经 `join(<dir>, MIPHAM_DIR, …)`。
3
8
  *
4
9
  * 写入一律落在 `.mipham/`(我们自己的目录);`.claude/` 只保留**只读兼容** ——
5
10
  * 早期版本把 worktree 建在 `.claude/worktrees/` 下,那些工作树今天仍要可列举、
@@ -120,3 +125,18 @@ export function workflowScriptDirs(cwd: string): string[] {
120
125
  join(homedir(), LEGACY_CLAUDE_DIR, 'workflows'),
121
126
  ]
122
127
  }
128
+
129
+ /* ── 用户级数据根 ──────────────────────────────────────────────────────── */
130
+
131
+ /**
132
+ * 用户级数据根目录 `~/.mipham`,后可接任意子路径。
133
+ *
134
+ * `homedir()` 在**调用时**求值,与改造前的调用点语义一致 —— 调用点若在模块作用域,
135
+ * 本函数也在那时求值。这不是可有可无的细节:`config/loader.ts` 的 `MIPHAM_HOME` 正是
136
+ * 模块作用域捕获的,测试靠 `vi.mock('node:os')` + `vi.hoisted` 抢在 import 之前装
137
+ * mock 才拦得住它(见 `test/telemetry/index.test.ts` 顶部那条注释)。故本函数**不得**
138
+ * 改成在别处预先算好再传进来 —— 那会把「调用时求值」换成「导入时求值」。
139
+ */
140
+ export function miphamHome(...segments: string[]): string {
141
+ return join(homedir(), MIPHAM_DIR, ...segments)
142
+ }