@miphamai/cli 0.83.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.
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +30 -8
- package/src/agent/agent-context.ts +5 -5
- package/src/agent/agent-experience.ts +2 -2
- package/src/agent/agent-registry.ts +4 -4
- package/src/agent/cross-session/discovery.ts +3 -2
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +2 -2
- package/src/agent/pattern-analyzer.ts +3 -4
- package/src/agent/sub-agent.ts +12 -2
- package/src/agent/types.ts +4 -1
- package/src/commands/autoloop-journal.ts +2 -2
- package/src/commands/environment.ts +2 -1
- package/src/commands/loop-scaffold.ts +2 -1
- package/src/commands/project.ts +86 -33
- package/src/config/keys-manager.ts +2 -2
- package/src/config/loader.ts +11 -10
- package/src/config/preferences.ts +3 -4
- package/src/core/auto-memory.ts +2 -3
- package/src/core/constitution-loader.ts +3 -4
- package/src/core/crsi-producer.ts +3 -3
- package/src/core/crsi-sandbox.ts +3 -2
- package/src/core/dream-engine.ts +2 -2
- package/src/core/engine.ts +38 -6
- package/src/core/error-signature-db.ts +2 -2
- package/src/core/eval-harness.ts +4 -3
- package/src/core/improvement-track.ts +5 -6
- package/src/core/instructions.ts +28 -4
- package/src/core/memory/memory-loader.ts +2 -3
- package/src/core/paths.ts +21 -1
- package/src/core/permission-audit.ts +120 -0
- package/src/core/permission-classifier.ts +449 -0
- package/src/core/permission-config.ts +106 -15
- package/src/core/permission.ts +369 -16
- package/src/core/rule-engine.ts +2 -2
- package/src/core/rules-loader.ts +3 -2
- package/src/core/session-log.ts +2 -3
- package/src/core/session-store.ts +2 -3
- package/src/core/workspace-trust.ts +4 -3
- package/src/daemon/database.ts +2 -2
- package/src/daemon/index.ts +2 -3
- package/src/daemon/launch.ts +3 -3
- package/src/daemon/server.ts +15 -0
- package/src/i18n-core/locales/en-US.json +3 -0
- package/src/i18n-core/locales/zh-CN.json +3 -0
- package/src/index.tsx +30 -6
- package/src/mcp/token-store.ts +2 -2
- package/src/plugin/plugin-manager.ts +2 -2
- package/src/shared/constants.ts +0 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +46 -6
- package/src/shared/update.ts +2 -3
- package/src/skills/bundled-skills.ts +1 -1
- package/src/skills/loader.ts +2 -3
- package/src/skills/marketplace.ts +2 -3
- package/src/skills/registry.ts +2 -2
- package/src/skills/skill-assets.ts +2 -2
- package/src/skills/usage.ts +2 -2
- package/src/telemetry/consent.ts +2 -3
- package/src/tools/agent/enter-plan.ts +3 -2
- package/src/tools/agent/exit-plan.ts +1 -1
- package/src/tools/agent/list-agents.ts +1 -1
- package/src/tools/agent/memory.ts +3 -3
- package/src/tools/agent/plan.ts +3 -2
- package/src/tools/agent/report-findings.ts +1 -1
- package/src/tools/agent/send-message.ts +1 -1
- package/src/tools/agent/skill.ts +1 -1
- package/src/tools/exec/git.ts +2 -2
- package/src/tools/exec/task.ts +1 -1
- package/src/tools/file/glob.ts +1 -1
- package/src/tools/file/grep.ts +1 -1
- package/src/tools/file/read.ts +1 -1
- package/src/tools/network/web-fetch.ts +1 -1
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/scheduling/cron.ts +5 -5
- package/src/tools/scheduling/schedule-wakeup.ts +1 -1
- package/src/tools/system/config.ts +2 -2
- package/src/tools/system/tool-search.ts +1 -1
- package/src/ui/app.tsx +51 -9
- package/src/ui/commands.ts +8 -17
- package/src/ui/config-wizard.tsx +2 -2
- package/src/ui/input.tsx +15 -3
- 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 =
|
|
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 =
|
|
28
|
+
const dir = miphamHome()
|
|
30
29
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
31
30
|
// 原子写:裸 writeFileSync 原地截断,崩在写中途就留下一份不可解析的文件,
|
|
32
31
|
// 而 readPrefs 把不可解析吞成「空」⇒ **全部**偏好静默消失(不是丢一项)。
|
package/src/core/auto-memory.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 {
|
|
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 ||
|
|
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 =
|
|
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 {
|
|
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
|
|
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(
|
|
540
|
+
mkdirSync(miphamHome('crsi'), { recursive: true })
|
|
541
541
|
appendFileSync(proseLedgerFile(), JSON.stringify(record) + '\n', 'utf-8')
|
|
542
542
|
} catch {
|
|
543
543
|
// ledger 非关键,失败不影响提议本身
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
90
|
+
const REPORT_DIR = miphamHome('crsi-sandbox')
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
93
|
* 自改进的「不可变基础」(immutable base)——按语义角色三类。
|
package/src/core/dream-engine.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
statSync,
|
|
36
36
|
} from 'node:fs'
|
|
37
37
|
import { join } from 'node:path'
|
|
38
|
-
import {
|
|
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 =
|
|
81
|
+
const DEFAULT_MEMORY_DIR = miphamHome('memory')
|
|
82
82
|
const STALE_DAYS = 30
|
|
83
83
|
const SIMILARITY_THRESHOLD = 0.65
|
|
84
84
|
|
package/src/core/engine.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 =
|
|
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
|
package/src/core/eval-harness.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import { tmpdir
|
|
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 =
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
225
|
+
mkdirSync(miphamHome('crsi'), { recursive: true })
|
|
227
226
|
atomicWriteFileSync(file, JSON.stringify({ verdict: v, timestamp: new Date().toISOString() }))
|
|
228
227
|
}
|
|
229
228
|
|
package/src/core/instructions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分类器裁决的本地台账 —— `~/.mipham/permission-audit.jsonl`。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要它。** 设计文档 §3.7(决策 5)与风险 8 记着同一条:子代理与后台是
|
|
5
|
+
* **无人值守**的(后台 / worktree / daemon 共用 `sub-agent.ts` 那条路径),而那道闸门
|
|
6
|
+
* 只有**拒绝**的通知路径 —— **放行是无声的**。`source: 'classifier'` 在
|
|
7
|
+
* `resolveApproval()` 里被造出来,随后只在**拒绝**分支上被两个闸门读走
|
|
8
|
+
* (`engine.ts` / `sub-agent.ts` 的 `decision.source === 'classifier'` 都嵌在拒绝那一支),
|
|
9
|
+
* 放行那一支的这个字段直接丢掉:没有会话日志事件、没有 metrics 计数器、也没有 hook 事件。
|
|
10
|
+
* 于是「`auto` 档到底批过什么」在本机没有任何一处读得出来。
|
|
11
|
+
*
|
|
12
|
+
* **为什么记在 `resolveApproval()` 里。** 那里是裁决的**出生地**,也是唯一一个天然覆盖
|
|
13
|
+
* 全部闸门的点:今天两个(`engine.ts` / `sub-agent.ts`),将来第三个也自动在内。反过来把
|
|
14
|
+
* 记录挂在两个闸门上,就是本仓库反复出现的那族缺陷的形状 —— 两条路径只接一条。更要紧的是
|
|
15
|
+
* 第二条理由:**子代理根本没有 `SessionLog`**(`SubAgent` 的构造函数里没有这个参数),
|
|
16
|
+
* 所以「走会话日志事件」这条更整齐的路在子代理那里无路可走,除非给它新拉一条日志管线。
|
|
17
|
+
* 这是相对原计划的一处**偏离**:原计划写的是会话日志事件,实测后改为这里的**模块级台账**
|
|
18
|
+
* (session-log 的 `checker/decision` 是同类先例,但那条先例只覆盖引擎,覆盖不到子代理)。
|
|
19
|
+
*
|
|
20
|
+
* **记什么、不记什么。** 只记「哪次调用、谁裁的、裁成什么、为什么」—— **绝不记工具入参**。
|
|
21
|
+
* 入参里会有文件正文、命令行、凭据片段。分类器的 `reason` 是模型生成的一句话,可能复述
|
|
22
|
+
* 入参,但它落在本机 0600 的文件里、不上网。这**不构成新的暴露面**:同一次调用在会话日志里
|
|
23
|
+
* 本来就以全量入参 + 全量结果的形式落盘了(`session-log.ts` 的「model-visible means logged」),
|
|
24
|
+
* 台账严格更少;子代理那条路径虽然没有会话日志,但仍是本机 0600、不经网络。
|
|
25
|
+
* **边界(是取舍,不是遗漏)**:没有入参 ⇒ 「`auto` 放行了哪一条 Bash」只能从 `reason` 里读,
|
|
26
|
+
* 读不到命令原文。要还原到那一层请去会话日志(引擎路径有,子代理路径没有)。
|
|
27
|
+
*
|
|
28
|
+
* **一条裁决 = 一行,不是一次执行 = 一行。** 记录写在分类器**真的被咨询**的那两处;
|
|
29
|
+
* `classifierCache` 命中**不写** —— 那时分类器根本没被问到(`resolveApproval()` 在调用它
|
|
30
|
+
* 之前就从缓存返回了),写一行等于声称有一个没人做过的裁决。反过来读:台账回答的是
|
|
31
|
+
* 「分类器裁过什么」,不是「某条命令跑了几次」;执行次数要问 gate 侧的指标或会话日志。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
35
|
+
import { dirname } from 'node:path'
|
|
36
|
+
import type { PermissionLevel, PermissionMode } from '../shared/index.ts'
|
|
37
|
+
import type { PermissionDenialReason } from './permission'
|
|
38
|
+
import { miphamHome } from './paths.ts'
|
|
39
|
+
|
|
40
|
+
/** 一条分类器裁决。 */
|
|
41
|
+
export interface ClassifierRulingRecord {
|
|
42
|
+
/** ISO 时间戳。 */
|
|
43
|
+
at: string
|
|
44
|
+
/** 裁决时的档位。今天只可能是 `'auto'`,写上它是为了让记录自证而不是靠读者推断。 */
|
|
45
|
+
mode: PermissionMode
|
|
46
|
+
tool: string
|
|
47
|
+
/** **分类器说了什么。** */
|
|
48
|
+
verdict: 'allow' | 'deny'
|
|
49
|
+
/**
|
|
50
|
+
* **这一支最终落定的档位。** 分类器放行后仍可能是 `'ask'` —— 放行走的是
|
|
51
|
+
* `allowRuleDecision()`,组织级 `maxAllowedMode` 会在那里封顶(`resolveApproval` 第 4 步)。
|
|
52
|
+
* 那种记录读作「分类器同意、上限否决」,`verdict` 与 `level` 两个字段合起来才说得清。
|
|
53
|
+
*/
|
|
54
|
+
level: PermissionLevel
|
|
55
|
+
/** 分类器自己的一句话理由(模型生成 —— 见文件头「记什么」)。缺省不写该键。 */
|
|
56
|
+
reason?: string
|
|
57
|
+
/** 仅拒绝:`true` ⇒ 引擎故障拿住,**不是策略决定**,重试是对的。缺省不写该键。 */
|
|
58
|
+
retryable?: boolean
|
|
59
|
+
/** 仅拒绝:拒绝的类别(策略拒绝是 `'classifier-deny'`)。缺省不写该键。 */
|
|
60
|
+
denialReason?: PermissionDenialReason
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 台账路径。**每次现算**,不存模块级常量 —— 测试对 `node:os` 的 `homedir` mock
|
|
65
|
+
* (全局 `vitest.setup.ts` 与文件级 `vi.mock` 两种)都因此一定生效,且不必依赖
|
|
66
|
+
* import 求值与 mock hoisting 的先后。`eval-harness.ts` 用模块级常量也能成立,
|
|
67
|
+
* 但那是「恰好也对」,这里不复制那个形状。
|
|
68
|
+
*/
|
|
69
|
+
export function permissionAuditPath(): string {
|
|
70
|
+
return miphamHome('permission-audit.jsonl')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 整个进程只说一次 —— 见 `recordClassifierRuling` 的失败分支。 */
|
|
74
|
+
let warnedOnce = false
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 追加一条裁决。**永不抛。**
|
|
78
|
+
*
|
|
79
|
+
* 一次台账写失败不该掀翻一次工具调用(可用性优先),但**也不能静默地失败** ——
|
|
80
|
+
* 这个模块存在的全部意义就是消掉「无声」,若写不进去还一声不响,等于把无声又装了回来。
|
|
81
|
+
* 折中是:调用方看不到异常,第一次失败往 stderr 说一句,之后不再重复。stderr 是本仓库
|
|
82
|
+
* 既有的告警通道(`hooks.ts` / `workspace-trust.ts` 同形)。
|
|
83
|
+
*/
|
|
84
|
+
export function recordClassifierRuling(record: Omit<ClassifierRulingRecord, 'at'>): void {
|
|
85
|
+
try {
|
|
86
|
+
const file = permissionAuditPath()
|
|
87
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
88
|
+
appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), ...record }) + '\n', {
|
|
89
|
+
encoding: 'utf-8',
|
|
90
|
+
// 只在创建时生效;已存在的文件不会被改权限。
|
|
91
|
+
mode: 0o600,
|
|
92
|
+
})
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (!warnedOnce) {
|
|
95
|
+
warnedOnce = true
|
|
96
|
+
process.stderr.write(
|
|
97
|
+
`⚠️ 分类器台账写不进去(之后不再重复报告): ${err instanceof Error ? err.message : String(err)}\n`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读回全部裁决。**撕裂行只跳过那一行** —— 写到一半进程被杀会留半行 JSON,
|
|
105
|
+
* 读侧不能因此整份报废(`improvement-track.ts` 的同形处理)。
|
|
106
|
+
*/
|
|
107
|
+
export function readClassifierRulings(): ClassifierRulingRecord[] {
|
|
108
|
+
const file = permissionAuditPath()
|
|
109
|
+
if (!existsSync(file)) return []
|
|
110
|
+
return readFileSync(file, 'utf-8')
|
|
111
|
+
.split('\n')
|
|
112
|
+
.filter((line) => line.trim() !== '')
|
|
113
|
+
.flatMap((line) => {
|
|
114
|
+
try {
|
|
115
|
+
return [JSON.parse(line) as ClassifierRulingRecord]
|
|
116
|
+
} catch {
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|