@miphamai/cli 0.83.0 → 0.85.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/bin/mipham.ts +20 -5
  2. package/package.json +1 -1
  3. package/skills/standard/mipham-code-setup.SKILL.md +30 -8
  4. package/src/agent/agent-context.ts +5 -5
  5. package/src/agent/agent-experience.ts +2 -2
  6. package/src/agent/agent-registry.ts +4 -4
  7. package/src/agent/cross-session/discovery.ts +3 -2
  8. package/src/agent/cross-session/file-inbox.ts +2 -2
  9. package/src/agent/effectiveness-tracker.ts +2 -2
  10. package/src/agent/pattern-analyzer.ts +3 -4
  11. package/src/agent/sub-agent.ts +12 -2
  12. package/src/agent/types.ts +4 -1
  13. package/src/commands/autoloop-journal.ts +2 -2
  14. package/src/commands/environment.ts +2 -1
  15. package/src/commands/loop-scaffold.ts +2 -1
  16. package/src/commands/project.ts +86 -33
  17. package/src/config/keys-manager.ts +2 -2
  18. package/src/config/loader.ts +11 -10
  19. package/src/config/preferences.ts +3 -4
  20. package/src/core/auto-memory.ts +2 -3
  21. package/src/core/constitution-loader.ts +3 -4
  22. package/src/core/crsi-producer.ts +3 -3
  23. package/src/core/crsi-sandbox.ts +3 -2
  24. package/src/core/dream-engine.ts +2 -2
  25. package/src/core/engine.ts +38 -6
  26. package/src/core/error-signature-db.ts +2 -2
  27. package/src/core/eval-harness.ts +4 -3
  28. package/src/core/improvement-track.ts +5 -6
  29. package/src/core/instructions.ts +28 -4
  30. package/src/core/memory/memory-loader.ts +2 -3
  31. package/src/core/paths.ts +21 -1
  32. package/src/core/permission-audit.ts +120 -0
  33. package/src/core/permission-classifier.ts +449 -0
  34. package/src/core/permission-config.ts +106 -15
  35. package/src/core/permission.ts +369 -16
  36. package/src/core/rule-engine.ts +2 -2
  37. package/src/core/rules-loader.ts +3 -2
  38. package/src/core/session-log.ts +2 -3
  39. package/src/core/session-store.ts +2 -3
  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 +7 -1
  46. package/src/i18n-core/locales/zh-CN.json +7 -1
  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 +255 -20
  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 +26 -21
  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
@@ -8,7 +8,6 @@ import {
8
8
  chmodSync,
9
9
  } from 'node:fs'
10
10
  import { join, dirname } from 'node:path'
11
- import { homedir } from 'node:os'
12
11
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
13
12
  import { atomicWriteFileSync } from '../shared/atomic-write'
14
13
  import type {
@@ -29,8 +28,10 @@ import {
29
28
  } from './defaults'
30
29
  import { getCredentialKey, encryptApiKey, decryptApiKey, ENC_PREFIX } from './credential-crypto'
31
30
  import type { SettingsHooks } from '../core/hooks-config'
31
+ import { miphamHome } from '../core/paths.ts'
32
+ import { MIPHAM_DIR } from '../shared/constants.ts'
32
33
 
33
- const MIPHAM_HOME = join(homedir(), '.mipham')
34
+ const MIPHAM_HOME = miphamHome()
34
35
  const BACKUP_PREFIX = 'config.backup-'
35
36
 
36
37
  /**
@@ -212,7 +213,7 @@ export function tryRestoreFromBackup(configPath: string): boolean {
212
213
  function loadMcpJson(cwd: string): McpServerConfig[] {
213
214
  const servers: McpServerConfig[] = []
214
215
  const searchPaths = [
215
- join(cwd, '.mipham', 'mcp.json'),
216
+ join(cwd, MIPHAM_DIR, 'mcp.json'),
216
217
  join(cwd, '.mcp.json'),
217
218
  join(MIPHAM_HOME, 'mcp.json'),
218
219
  ]
@@ -294,7 +295,7 @@ export function loadSettingsJson(
294
295
 
295
296
  const searchPaths: Array<{ path: string; readHooks: boolean; isProject?: boolean }> = [
296
297
  {
297
- path: join(cwd, '.mipham', 'settings.json'),
298
+ path: join(cwd, MIPHAM_DIR, 'settings.json'),
298
299
  readHooks: options.includeProjectHooks ?? false,
299
300
  isProject: true,
300
301
  },
@@ -366,7 +367,7 @@ export type SettingsScope = 'project' | 'user'
366
367
  export function settingsPathFor(scope: SettingsScope, cwd: string = process.cwd()): string {
367
368
  return scope === 'user'
368
369
  ? join(MIPHAM_HOME, 'settings.json')
369
- : join(cwd, '.mipham', 'settings.json')
370
+ : join(cwd, MIPHAM_DIR, 'settings.json')
370
371
  }
371
372
 
372
373
  /**
@@ -442,7 +443,7 @@ export function removeSettingsRule(
442
443
  }
443
444
 
444
445
  export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
445
- const configPath = join(cwd, '.mipham', 'config.yml')
446
+ const configPath = join(cwd, MIPHAM_DIR, 'config.yml')
446
447
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
447
448
 
448
449
  let config = { ...DEFAULT_CONFIG }
@@ -629,7 +630,7 @@ function mergeCredentialMaskingFile(
629
630
  export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
630
631
  let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
631
632
  merged = mergeCredentialMaskingFile(merged, join(MIPHAM_HOME, 'config.yml'), true)
632
- merged = mergeCredentialMaskingFile(merged, join(cwd, '.mipham', 'config.yml'), false)
633
+ merged = mergeCredentialMaskingFile(merged, join(cwd, MIPHAM_DIR, 'config.yml'), false)
633
634
  return merged
634
635
  }
635
636
 
@@ -655,7 +656,7 @@ export function loadUserCredentialMaskingConfig(): CredentialMaskingConfig {
655
656
  * Load background agent configuration from config sources.
656
657
  */
657
658
  export function loadBackgroundAgentConfig(cwd: string = process.cwd()): BackgroundAgentConfig {
658
- const configPath = join(cwd, '.mipham', 'config.yml')
659
+ const configPath = join(cwd, MIPHAM_DIR, 'config.yml')
659
660
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
660
661
 
661
662
  let merged = { ...DEFAULT_BACKGROUND_AGENT_CONFIG }
@@ -688,7 +689,7 @@ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): Backgrou
688
689
  * Merges project-level over user-level. Returns defaults if no section present.
689
690
  */
690
691
  export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessionConfig {
691
- const configPath = join(cwd, '.mipham', 'config.yml')
692
+ const configPath = join(cwd, MIPHAM_DIR, 'config.yml')
692
693
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
693
694
 
694
695
  let merged = { ...DEFAULT_CROSS_SESSION_CONFIG }
@@ -751,7 +752,7 @@ function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
751
752
  */
752
753
  export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
753
754
  const userConfigPath = join(MIPHAM_HOME, 'config.yml')
754
- const projectConfigPath = join(cwd, '.mipham', 'config.yml')
755
+ const projectConfigPath = join(cwd, MIPHAM_DIR, 'config.yml')
755
756
  const configPath = existsSync(userConfigPath) ? userConfigPath : projectConfigPath
756
757
 
757
758
  try {
@@ -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,7 +96,7 @@ 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')
99
100
 
100
101
  /** 落盘的契约粒度投影 —— 只要 id/passed/role(EvalResult 的 description/detail 不落盘)。 */
101
102
  export interface ContractResultRecord {
@@ -117,7 +118,7 @@ export function appendEvalScore(
117
118
  report: { score: number; passed: number; total: number; results?: EvalResult[] },
118
119
  ): void {
119
120
  try {
120
- mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
121
+ mkdirSync(miphamHome('crsi'), { recursive: true })
121
122
  appendFileSync(
122
123
  SCORES_FILE,
123
124
  JSON.stringify({
@@ -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
 
@@ -181,12 +180,12 @@ export function formatCostLine(report: ImprovementReport): string | null {
181
180
  // ── 台账 ──
182
181
 
183
182
  export function improvementPath(): string {
184
- return join(homedir(), '.mipham', 'crsi', 'improvements.jsonl')
183
+ return miphamHome('crsi', 'improvements.jsonl')
185
184
  }
186
185
 
187
186
  export function appendImprovement(record: ImprovementRecord): void {
188
187
  const file = improvementPath()
189
- mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
188
+ mkdirSync(miphamHome('crsi'), { recursive: true })
190
189
  // 原子激活(④):整账本读-改-写 + temp 文件 rename(见 shared/atomic-write),读者要么见旧要么见新。
191
190
  // 非原子的 appendFileSync 写中途崩溃会留撕裂行,readImprovements 会 JSON.parse 抛错。
192
191
  const existing = readImprovements()
@@ -214,7 +213,7 @@ export function readImprovements(): ImprovementRecord[] {
214
213
  // (temp+rename,见 shared/atomic-write),替代易失内存变量(进程重启即丢、无 manifest)。
215
214
 
216
215
  export function pendingVerdictPath(): string {
217
- return join(homedir(), '.mipham', 'crsi', 'pending-verdict.json')
216
+ return miphamHome('crsi', 'pending-verdict.json')
218
217
  }
219
218
 
220
219
  export function setPendingVerdict(v: ImprovementVerdict | null): void {
@@ -223,7 +222,7 @@ export function setPendingVerdict(v: ImprovementVerdict | null): void {
223
222
  rmSync(file, { force: true }) // 原子清除(unlink 原子)
224
223
  return
225
224
  }
226
- mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
225
+ mkdirSync(miphamHome('crsi'), { recursive: true })
227
226
  atomicWriteFileSync(file, JSON.stringify({ verdict: v, timestamp: new Date().toISOString() }))
228
227
  }
229
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
+ }