@miphamai/cli 0.81.8 → 0.82.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/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +7 -1
- package/src/config/loader.ts +202 -63
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/crsi-modify.ts +34 -1
- package/src/core/crsi-producer.ts +77 -7
- package/src/core/crsi-sandbox.ts +65 -0
- package/src/core/engine.ts +7 -2
- package/src/core/eval-harness.ts +77 -4
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/improvement-track.ts +41 -0
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +17 -2
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +5 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +44 -17
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +13 -2
- package/src/providers/anthropic.ts +48 -11
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +19 -1
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/exec/bash.ts +106 -6
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +76 -1
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +33 -3
- package/src/tools/index.ts +12 -4
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +205 -30
- package/src/workflow/primitives/agent.ts +4 -0
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync
|
|
|
19
19
|
import { join, resolve, sep, posix } from 'node:path'
|
|
20
20
|
import { tmpdir, homedir } from 'node:os'
|
|
21
21
|
import { randomUUID } from 'node:crypto'
|
|
22
|
+
import { LESSONS_FILE, MANAGED_RULES_FILE } from './crsi-producer'
|
|
22
23
|
|
|
23
24
|
// ── Types ──
|
|
24
25
|
|
|
@@ -195,6 +196,70 @@ export function validateBlastRadius(proposal: {
|
|
|
195
196
|
return null
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
/**
|
|
200
|
+
* 脚手架三项计数(B_H 的度量)。按 filePath 分派语义单位:
|
|
201
|
+
* 教训段数 / 受管理规则条数 / 其余按 UTF-8 字节数。
|
|
202
|
+
*
|
|
203
|
+
* **为什么教训/规则文件不计字节**:合并会重写散文,字节数随措辞涨落。把字节计入,
|
|
204
|
+
* 会让「删二增一」因新写的合并段比原来两段更长而被误拦 —— 即闸会挡掉它本该允许的那件事。
|
|
205
|
+
* skill 文件没有可用的语义单位(它的「条数」就是文件本身),才退到字节数。
|
|
206
|
+
*
|
|
207
|
+
* `## ` 的口径与 `removeLessonSections` 逐字一致 —— 闸数的必须是 crossover 真正删得掉的那些
|
|
208
|
+
* 单位,否则两把尺子会各说各话。**刻意不声称与 `extractCrsiLessonSummaries` 一致**:后者用
|
|
209
|
+
* `/^##\s+(.+?)\s*$/`,还认 `##\t`,而 `startsWith('## ')` 不认(`'##\ta: 1'` 在此计 0、
|
|
210
|
+
* 在那里计 1)。闸依赖的是「删得掉」,故按前者对齐;这个差是选择,不是遗漏。
|
|
211
|
+
*
|
|
212
|
+
* 分派按**解析后**的路径(`resolve` 两侧同调,`cwd` 相消)—— 字面量比较时,`./` 前缀或
|
|
213
|
+
* 绝对形式的教训路径会静默落到**字节**分支,而那正是上面说绝不该用在教训文件上的那把尺子。
|
|
214
|
+
* 兄弟守卫 `isProtectedPath` 本身不做规范化(纯前缀比较);是调用点 `CrsiSandbox.applyModification`
|
|
215
|
+
* 先 `posix.normalize` 再调它(`proposal-guard.ts` 那条调用点未规范化)。
|
|
216
|
+
*/
|
|
217
|
+
export function measureScaffold(
|
|
218
|
+
filePath: string,
|
|
219
|
+
content: string,
|
|
220
|
+
): { lessons: number; rules: number; bytes: number } {
|
|
221
|
+
if (resolve(filePath) === resolve(LESSONS_FILE)) {
|
|
222
|
+
const lessons = content.split('\n').filter((l) => l.startsWith('## ')).length
|
|
223
|
+
return { lessons, rules: 0, bytes: 0 }
|
|
224
|
+
}
|
|
225
|
+
if (resolve(filePath) === resolve(MANAGED_RULES_FILE)) {
|
|
226
|
+
const rules = (content.match(/id: '/g) ?? []).length
|
|
227
|
+
return { lessons: 0, rules, bytes: 0 }
|
|
228
|
+
}
|
|
229
|
+
return { lessons: 0, rules: 0, bytes: Buffer.byteLength(content, 'utf-8') }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 合并型提案的收敛闸(B_H)。**零写死常数** —— 它不设上界,只要求「合并这件事本身别把
|
|
234
|
+
* 脚手架抬高」。RSIH 的 ‖H‖≤B_H 需要一个数,是因为它必须允许学到上界、到顶再强制合并;
|
|
235
|
+
* 本仓已有 dedup 那一半(教训按 `## category: title` 幂等、规则按 id 幂等),
|
|
236
|
+
* 缺的只是另一半,而那一半不需要数:**闸只在「试图整合」那一刻开火**。
|
|
237
|
+
*
|
|
238
|
+
* 返回拒绝理由,合法时返回 null(同 validateBlastRadius 的签名)。
|
|
239
|
+
*/
|
|
240
|
+
export function validateMergeConvergence(proposal: {
|
|
241
|
+
filePath?: string
|
|
242
|
+
originalContent?: string
|
|
243
|
+
newContent?: string
|
|
244
|
+
merge?: boolean
|
|
245
|
+
}): string | null {
|
|
246
|
+
if (proposal.merge !== true) return null
|
|
247
|
+
// 无基线不是有增长:手工路径在文件不存在时正是这个形态(commands.ts 的宽松模式)。
|
|
248
|
+
if (!proposal.originalContent || !proposal.newContent) return null
|
|
249
|
+
|
|
250
|
+
const filePath = proposal.filePath ?? ''
|
|
251
|
+
const before = measureScaffold(filePath, proposal.originalContent)
|
|
252
|
+
const after = measureScaffold(filePath, proposal.newContent)
|
|
253
|
+
|
|
254
|
+
const rose: string[] = []
|
|
255
|
+
if (after.lessons > before.lessons) rose.push(`教训段数 ${before.lessons} → ${after.lessons}`)
|
|
256
|
+
if (after.rules > before.rules) rose.push(`规则条数 ${before.rules} → ${after.rules}`)
|
|
257
|
+
if (after.bytes > before.bytes) rose.push(`字节数 ${before.bytes} → ${after.bytes}`)
|
|
258
|
+
if (rose.length === 0) return null
|
|
259
|
+
|
|
260
|
+
return `合并型提案必须收敛,但脚手架增长了:${rose.join(';')}。`
|
|
261
|
+
}
|
|
262
|
+
|
|
198
263
|
// ── Sandbox ──
|
|
199
264
|
|
|
200
265
|
export class CrsiSandbox {
|
package/src/core/engine.ts
CHANGED
|
@@ -256,11 +256,16 @@ export class QueryEngine {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
if (policy === 'ask') {
|
|
259
|
-
// Mark as awaiting approval — the model should verify with the user before acting
|
|
259
|
+
// Mark as awaiting approval — the model should verify with the user before acting.
|
|
260
|
+
//
|
|
261
|
+
// The instruction goes in the *summary*, because that is the only field
|
|
262
|
+
// `formatInboundMessage` delivers. Putting it in the body (as it was) is
|
|
263
|
+
// how a consent gate ends up authored and never applied: the recipient saw
|
|
264
|
+
// "[Awaiting Approval]" but not what to do about it.
|
|
260
265
|
bus.post(
|
|
261
266
|
msg.from,
|
|
262
267
|
msg.to,
|
|
263
|
-
`[Awaiting Approval] ${msg.summary}`,
|
|
268
|
+
`[Awaiting Approval — verify with the user before acting] ${msg.summary}`,
|
|
264
269
|
`[Cross-session message from ${msg.from} — verify with user before acting]\n\n${msg.message}`,
|
|
265
270
|
'warning',
|
|
266
271
|
)
|
package/src/core/eval-harness.ts
CHANGED
|
@@ -21,14 +21,21 @@ import { PreFlightChecker } from './preflight-checker'
|
|
|
21
21
|
import { createDefaultPostFlightChecker } from './post-flight-checker'
|
|
22
22
|
import { WorkingMemory } from './working-memory'
|
|
23
23
|
import { RedTeam } from './red-team'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
isProtectedPath,
|
|
26
|
+
validateBlastRadius,
|
|
27
|
+
validateMergeConvergence,
|
|
28
|
+
PROTECTED_CRITICAL_FILES,
|
|
29
|
+
} from './crsi-sandbox'
|
|
25
30
|
import {
|
|
26
31
|
produceRuleProposal,
|
|
27
32
|
MANAGED_RULES_FILE,
|
|
33
|
+
LESSONS_FILE,
|
|
28
34
|
buildLessonContent,
|
|
29
35
|
renderManagedRuleSource,
|
|
30
36
|
} from './crsi-producer'
|
|
31
37
|
import type { CrsiSignal } from './crsi-producer'
|
|
38
|
+
import { predictionHit } from './improvement-track'
|
|
32
39
|
import { loadBehaviorTasks, judgeBehaviorTask } from './behavior-tasks'
|
|
33
40
|
|
|
34
41
|
// ── Types ──
|
|
@@ -43,6 +50,12 @@ export interface EvalResult {
|
|
|
43
50
|
detail?: string
|
|
44
51
|
/** 契约角色。缺省 neutral。 */
|
|
45
52
|
role?: ContractRole
|
|
53
|
+
/**
|
|
54
|
+
* anchor 契约的**定义处真源**。`role` 由此派生(见 runEval 末尾的回填)。
|
|
55
|
+
* `ANCHOR_CONTRACT_IDS` 退为**独立声明**,两者由 test/integrity/anchor-contract-wiring
|
|
56
|
+
* 守卫两向比对 —— 两处独立陈述同一件事,它们才可能不一致,守卫才有内容。
|
|
57
|
+
*/
|
|
58
|
+
anchor?: true
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
export interface EvalReport {
|
|
@@ -70,6 +83,8 @@ export const ANCHOR_CONTRACT_IDS: ReadonlySet<string> = new Set([
|
|
|
70
83
|
'red-team-zero-gaps',
|
|
71
84
|
'producer-rule-shape',
|
|
72
85
|
'producer-rule-idempotent',
|
|
86
|
+
'prediction-hit-truth-table',
|
|
87
|
+
'merge-convergence-gate',
|
|
73
88
|
'self-report-diagnostic',
|
|
74
89
|
])
|
|
75
90
|
|
|
@@ -147,6 +162,7 @@ export function runEval(): EvalReport {
|
|
|
147
162
|
id: 'rule-timeout',
|
|
148
163
|
description: '内置 timeout 规则命中低超时的 npm install',
|
|
149
164
|
passed: timeout.modified.timeout === 300000,
|
|
165
|
+
anchor: true,
|
|
150
166
|
})
|
|
151
167
|
|
|
152
168
|
const gitForce = ruleEngine.intercept('Bash', {
|
|
@@ -157,6 +173,7 @@ export function runEval(): EvalReport {
|
|
|
157
173
|
id: 'rule-git-force',
|
|
158
174
|
description: 'git --force 触发告警',
|
|
159
175
|
passed: gitForce.warnings.length > 0,
|
|
176
|
+
anchor: true,
|
|
160
177
|
})
|
|
161
178
|
|
|
162
179
|
const disabledRule: import('./rule-engine').ToolRule = {
|
|
@@ -174,6 +191,7 @@ export function runEval(): EvalReport {
|
|
|
174
191
|
id: 'rule-disabled-skip',
|
|
175
192
|
description: '禁用规则被跳过',
|
|
176
193
|
passed: disabled.warnings.length === 0,
|
|
194
|
+
anchor: true,
|
|
177
195
|
})
|
|
178
196
|
|
|
179
197
|
// ── 宪法(ground truth:8 原则 + facet 映射 + 愿力序言) ──
|
|
@@ -182,6 +200,7 @@ export function runEval(): EvalReport {
|
|
|
182
200
|
id: 'constitution-8-principles',
|
|
183
201
|
description: '宪法含 8 条原则',
|
|
184
202
|
passed: principles.length === 8,
|
|
203
|
+
anchor: true,
|
|
185
204
|
})
|
|
186
205
|
|
|
187
206
|
const prajna = principles.filter((p) => p.facet === 'prajna').length
|
|
@@ -191,12 +210,14 @@ export function runEval(): EvalReport {
|
|
|
191
210
|
id: 'constitution-facets',
|
|
192
211
|
description: 'facet 映射 智3 / 金刚5 / 悲0',
|
|
193
212
|
passed: prajna === 3 && vajra === 5 && karuna === 0,
|
|
213
|
+
anchor: true,
|
|
194
214
|
})
|
|
195
215
|
|
|
196
216
|
results.push({
|
|
197
217
|
id: 'constitution-preamble',
|
|
198
218
|
description: '愿力序言已注入',
|
|
199
219
|
passed: !!DEFAULT_CONSTITUTION.preamble && DEFAULT_CONSTITUTION.preamble.includes('悲'),
|
|
220
|
+
anchor: true,
|
|
200
221
|
})
|
|
201
222
|
|
|
202
223
|
// ── 沙箱只读边界(ground truth:受保护路径被拒) ──
|
|
@@ -206,7 +227,12 @@ export function runEval(): EvalReport {
|
|
|
206
227
|
['sandbox-protected-machinery', 'apps/cli/src/core/crsi-sandbox.ts'],
|
|
207
228
|
]
|
|
208
229
|
for (const [id, path] of protectedChecks) {
|
|
209
|
-
results.push({
|
|
230
|
+
results.push({
|
|
231
|
+
id,
|
|
232
|
+
description: `受保护路径被拒: ${path}`,
|
|
233
|
+
passed: isProtectedPath(path),
|
|
234
|
+
anchor: true,
|
|
235
|
+
})
|
|
210
236
|
}
|
|
211
237
|
|
|
212
238
|
// ── 语义边界完整性(ground truth:金丝雀关键机制文件全覆盖) ──
|
|
@@ -216,6 +242,7 @@ export function runEval(): EvalReport {
|
|
|
216
242
|
description: '语义保护边界覆盖全部关键机制文件(评估器 + 核心机制)',
|
|
217
243
|
passed: unprotected.length === 0,
|
|
218
244
|
...(unprotected.length > 0 ? { detail: `未保护: ${unprotected.join(', ')}` } : {}),
|
|
245
|
+
anchor: true,
|
|
219
246
|
})
|
|
220
247
|
|
|
221
248
|
// ── 完整覆盖闸(ground truth:未声明 blast radius 的 proposal 被 fail-closed 拒绝) ──
|
|
@@ -226,6 +253,7 @@ export function runEval(): EvalReport {
|
|
|
226
253
|
validateBlastRadius({ blastRadius: undefined }) !== null &&
|
|
227
254
|
validateBlastRadius({ blastRadius: [] }) !== null &&
|
|
228
255
|
validateBlastRadius({ blastRadius: ['apps/cli/src/foo.ts'] }) === null,
|
|
256
|
+
anchor: true,
|
|
229
257
|
})
|
|
230
258
|
|
|
231
259
|
// ── 安全(ground truth:16 攻击零漏过) ──
|
|
@@ -235,6 +263,7 @@ export function runEval(): EvalReport {
|
|
|
235
263
|
description: '16 个对抗场景零漏过',
|
|
236
264
|
passed: redTeam.passedThrough === 0,
|
|
237
265
|
detail: `score=${redTeam.score}, passedThrough=${redTeam.passedThrough}, falsePositives=${redTeam.falsePositives}`,
|
|
266
|
+
anchor: true,
|
|
238
267
|
})
|
|
239
268
|
|
|
240
269
|
// ── producer 行为(ground truth:固化规则产出正确 shape + 幂等) ──
|
|
@@ -255,6 +284,7 @@ export function runEval(): EvalReport {
|
|
|
255
284
|
ruleProposal.newContent.includes("source: 'managed'") &&
|
|
256
285
|
ruleProposal.newContent.includes('timeout: 300000') &&
|
|
257
286
|
ruleProposal.newContent.includes('enabled: true'),
|
|
287
|
+
anchor: true,
|
|
258
288
|
})
|
|
259
289
|
|
|
260
290
|
results.push({
|
|
@@ -262,6 +292,7 @@ export function runEval(): EvalReport {
|
|
|
262
292
|
description: '同名规则重复产出被拒(幂等)',
|
|
263
293
|
passed:
|
|
264
294
|
ruleProposal !== null && produceRuleProposal(frozenSignal, ruleProposal.newContent) === null,
|
|
295
|
+
anchor: true,
|
|
265
296
|
})
|
|
266
297
|
|
|
267
298
|
// ── 组件归因(ground truth:缺省 experiential、显式组件透传、非 experiential 不进 managed-rule) ──
|
|
@@ -334,6 +365,46 @@ export function runEval(): EvalReport {
|
|
|
334
365
|
results.push({ ...judgeBehaviorTask(task, ruleEngine), role: 'target' })
|
|
335
366
|
}
|
|
336
367
|
|
|
368
|
+
// ── ε 预测命中真值表(ground truth:命中判据不叠加统计阈值) ──
|
|
369
|
+
// `(20, 20)` 那条**承重**:判据是 `deltaMean >= predicted` 而 `>=` 与 `>` 只在
|
|
370
|
+
// `predicted === deltaMean` 处分歧 ⇒ 少了它,「把 >= 翻成 >」在契约上不可观测。
|
|
371
|
+
results.push({
|
|
372
|
+
id: 'prediction-hit-truth-table',
|
|
373
|
+
description: 'predictionHit 真值表(返回值):未达不算、达到或恰好相等算命中、缺席恒 false',
|
|
374
|
+
passed:
|
|
375
|
+
predictionHit(50, 20) === false &&
|
|
376
|
+
predictionHit(10, 20) === true &&
|
|
377
|
+
predictionHit(20, 20) === true &&
|
|
378
|
+
predictionHit(undefined, 20) === false,
|
|
379
|
+
anchor: true,
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ── B_H 合并型收敛闸(ground truth:净增被拒、删二增一通过、非合并型不受此闸) ──
|
|
383
|
+
results.push({
|
|
384
|
+
id: 'merge-convergence-gate',
|
|
385
|
+
description: '合并型净增被拒、删二增一通过、merge=false 净增通过',
|
|
386
|
+
passed:
|
|
387
|
+
validateMergeConvergence({
|
|
388
|
+
filePath: LESSONS_FILE,
|
|
389
|
+
originalContent: '## a: 1\n\n## b: 2\n',
|
|
390
|
+
newContent: '## a: 1\n\n## b: 2\n\n## c: 3\n',
|
|
391
|
+
merge: true,
|
|
392
|
+
}) !== null &&
|
|
393
|
+
validateMergeConvergence({
|
|
394
|
+
filePath: LESSONS_FILE,
|
|
395
|
+
originalContent: '## a: 1\n\n## b: 2\n',
|
|
396
|
+
newContent: '## ab: merged\n',
|
|
397
|
+
merge: true,
|
|
398
|
+
}) === null &&
|
|
399
|
+
validateMergeConvergence({
|
|
400
|
+
filePath: LESSONS_FILE,
|
|
401
|
+
originalContent: '## a: 1\n',
|
|
402
|
+
newContent: '## a: 1\n\n## b: 2\n',
|
|
403
|
+
merge: false,
|
|
404
|
+
}) === null,
|
|
405
|
+
anchor: true,
|
|
406
|
+
})
|
|
407
|
+
|
|
337
408
|
// ── 自报分数只作诊断:评分路径无 LLM,分数来自 ground-truth 契约而非模型自报 ──
|
|
338
409
|
// anchor 锁死「评分组件不暴露 LLM 的 chat 能力」。4 个组件(ruleEngine/constitution/
|
|
339
410
|
// errorDB/preflight)都是确定性组件(runEval 同步评分)。若未来有人把 LLM 注入评分
|
|
@@ -347,11 +418,13 @@ export function runEval(): EvalReport {
|
|
|
347
418
|
id: 'self-report-diagnostic',
|
|
348
419
|
description: '评分无 LLM:机制哨兵组件不暴露 chat 能力(分数只来自 ground-truth,非模型自报)',
|
|
349
420
|
passed: !llmInjected,
|
|
421
|
+
anchor: true,
|
|
350
422
|
})
|
|
351
423
|
|
|
352
|
-
// 角色标注:anchor
|
|
424
|
+
// 角色标注:anchor 由契约**定义处内联的标记**派生(真源),
|
|
425
|
+
// ANCHOR_CONTRACT_IDS 退为独立声明 —— 两者由 anchor-contract-wiring 守卫两向比对。
|
|
353
426
|
for (const r of results) {
|
|
354
|
-
if (
|
|
427
|
+
if (r.anchor) r.role = 'anchor'
|
|
355
428
|
}
|
|
356
429
|
|
|
357
430
|
// anchor 自检(ground truth:所有 anchor 契约必须全绿,否则门拒)。
|
|
@@ -106,20 +106,48 @@ export function parseHookStdout(stdout: string | null | undefined, _ctx: HookCon
|
|
|
106
106
|
return { allowed: true }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function executeCommand(cfg: HookConfig, ctx: HookContext): HookResult {
|
|
109
|
+
async function executeCommand(cfg: HookConfig, ctx: HookContext): Promise<HookResult> {
|
|
110
110
|
if (!cfg.command) return { allowed: true }
|
|
111
111
|
|
|
112
112
|
try {
|
|
113
113
|
const args = cfg.args ? cfg.args.map((a) => substituteVars(a, ctx)) : []
|
|
114
114
|
|
|
115
|
+
// A hook command is a child of this process, so a bare `spawnSync` would hand
|
|
116
|
+
// it the whole environment — every provider key and bot secret included. Bash
|
|
117
|
+
// has been masking these since E1; hooks were the remaining door, so they use
|
|
118
|
+
// the same policy. Resolved at **user level** — the same choice E1 made for
|
|
119
|
+
// every spawn it could not scope to one session's project section
|
|
120
|
+
// (`tools/index.ts:45-48`). Scoping it to `ctx.cwd` now that this file has one
|
|
121
|
+
// would be a masking-policy change, not a plumbing fix, so it is not made here.
|
|
122
|
+
const { loadUserCredentialMaskingConfig } = await import('../config/loader')
|
|
123
|
+
const { filterEnv } = await import('./credential-masker')
|
|
124
|
+
const masking = loadUserCredentialMaskingConfig()
|
|
125
|
+
const env =
|
|
126
|
+
masking.enabled && masking.env_filter.enabled
|
|
127
|
+
? filterEnv(process.env as Record<string, string | undefined>, masking)
|
|
128
|
+
: undefined
|
|
129
|
+
|
|
130
|
+
// Which workspace the hook is *for* is the session's business, not this
|
|
131
|
+
// process's — the daemon runs many sessions and its own cwd belongs to none of
|
|
132
|
+
// them. `HookEngine` stamps `ctx.cwd`; the fallback covers a context built by
|
|
133
|
+
// hand, and is exactly right for the one-shot CLI.
|
|
134
|
+
const cwd = ctx.cwd ?? process.cwd()
|
|
135
|
+
|
|
115
136
|
// Use spawnSync with array args — no shell, no command injection.
|
|
116
137
|
// Pass the Claude-protocol stdin JSON so scripts can read structured context.
|
|
117
|
-
const input = JSON.stringify(buildHookStdin(ctx,
|
|
138
|
+
const input = JSON.stringify(buildHookStdin(ctx, cwd))
|
|
118
139
|
const result = spawnSync(cfg.command, args, {
|
|
119
140
|
timeout: (cfg.timeout ?? 60) * 1000,
|
|
120
141
|
encoding: 'utf-8',
|
|
121
142
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
122
143
|
input,
|
|
144
|
+
// Both halves of "where is this hook": the directory it runs in, and the
|
|
145
|
+
// `cwd` it reads off stdin. Fixing only one leaves the hook told it is
|
|
146
|
+
// somewhere it is not.
|
|
147
|
+
cwd,
|
|
148
|
+
// `undefined` = inherit, which is what node does by default; passing it
|
|
149
|
+
// explicitly keeps the two branches visible at one site.
|
|
150
|
+
env,
|
|
123
151
|
})
|
|
124
152
|
|
|
125
153
|
// Exit code 0 = success — parse the stdout JSON for structured decisions.
|
package/src/core/hooks.ts
CHANGED
|
@@ -48,6 +48,18 @@ export class HookEngine {
|
|
|
48
48
|
/** Health tracking per hook key (event[:toolName]) */
|
|
49
49
|
private health = new Map<string, HookHealth>()
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The workspace this engine's hooks run *for*.
|
|
53
|
+
*
|
|
54
|
+
* Settled at construction because one engine belongs to one session, not to one
|
|
55
|
+
* hook. The default is exactly right for the one-shot CLI, whose process cwd
|
|
56
|
+
* *is* the session cwd — but the daemon serves many sessions from one process,
|
|
57
|
+
* so it must pass the session's cwd. Left to the executor's own
|
|
58
|
+
* `process.cwd()`, every daemon hook would run in — and be told it is in — the
|
|
59
|
+
* directory the daemon happened to be started from.
|
|
60
|
+
*/
|
|
61
|
+
constructor(private readonly cwd: string = process.cwd()) {}
|
|
62
|
+
|
|
51
63
|
register(hook: HookDefinition): void {
|
|
52
64
|
this.hooks.push(hook)
|
|
53
65
|
}
|
|
@@ -98,7 +110,12 @@ export class HookEngine {
|
|
|
98
110
|
|
|
99
111
|
async executeStop(sessionId: string): Promise<HookResult> {
|
|
100
112
|
const ctx: HookContext = { event: 'Stop', sessionId }
|
|
101
|
-
|
|
113
|
+
const result = await this.runHooks('Stop', undefined, ctx)
|
|
114
|
+
|
|
115
|
+
// A blocking Stop hook arrives as a deny (exit code 2, `decision: block`, or
|
|
116
|
+
// `continue: false`), but the engine's Stop path reads `decision`. Derive it
|
|
117
|
+
// here rather than at each producer so every form reaches "do not stop yet".
|
|
118
|
+
return result.allowed ? result : { ...result, decision: 'block' }
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
async executeUserPromptSubmit(prompt: string, sessionId: string): Promise<HookResult> {
|
|
@@ -134,9 +151,12 @@ export class HookEngine {
|
|
|
134
151
|
const ctx: HookContext = {
|
|
135
152
|
event: 'SubagentStart',
|
|
136
153
|
sessionId,
|
|
154
|
+
// The agent type plays the role a tool name plays for PreToolUse: it is
|
|
155
|
+
// what a settings.json `matcher` selects on.
|
|
156
|
+
toolName: agentType,
|
|
137
157
|
toolInput: { agentType, description },
|
|
138
158
|
}
|
|
139
|
-
return this.runHooks('SubagentStart',
|
|
159
|
+
return this.runHooks('SubagentStart', agentType, ctx)
|
|
140
160
|
}
|
|
141
161
|
|
|
142
162
|
async executeSubagentStop(
|
|
@@ -149,10 +169,12 @@ export class HookEngine {
|
|
|
149
169
|
const ctx: HookContext = {
|
|
150
170
|
event: 'SubagentStop',
|
|
151
171
|
sessionId,
|
|
172
|
+
// Matcher target — see executeSubagentStart.
|
|
173
|
+
toolName: agentType,
|
|
152
174
|
toolInput: { agentType, description, success },
|
|
153
175
|
toolResult: result ? { success, content: result.slice(0, 2000) } : undefined,
|
|
154
176
|
}
|
|
155
|
-
return this.runHooks('SubagentStop',
|
|
177
|
+
return this.runHooks('SubagentStop', agentType, ctx)
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
async executePostToolUseFailure(
|
|
@@ -268,15 +290,40 @@ export class HookEngine {
|
|
|
268
290
|
|
|
269
291
|
// ── Core execution ──
|
|
270
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Does a hook's `matcher` select this invocation?
|
|
295
|
+
*
|
|
296
|
+
* No matcher means every invocation of the event. Otherwise the stored matcher
|
|
297
|
+
* is a regex — `loadHookConfigs` compiles it as one — tested against the name
|
|
298
|
+
* this event filters on: the tool name for tool events, the agent type for the
|
|
299
|
+
* subagent events. Events that carry no such name (Stop, SessionStart, …) are
|
|
300
|
+
* not filtered here.
|
|
301
|
+
*/
|
|
302
|
+
private matchesMatcher(matcher: string | undefined, name: string | undefined): boolean {
|
|
303
|
+
if (!matcher || !name) return true
|
|
304
|
+
try {
|
|
305
|
+
return new RegExp(matcher).test(name)
|
|
306
|
+
} catch {
|
|
307
|
+
// An uncompilable pattern cannot get this far through loadHookConfigs,
|
|
308
|
+
// which compiles every matcher when it loads. Keep such a hook running
|
|
309
|
+
// rather than dropping it silently.
|
|
310
|
+
return true
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
271
314
|
private async runHooks(
|
|
272
315
|
event: HookEvent,
|
|
273
316
|
toolName: string | undefined,
|
|
274
317
|
ctx: HookContext,
|
|
275
318
|
): Promise<HookResult> {
|
|
276
319
|
const matching = this.hooks.filter(
|
|
277
|
-
(h) => h.event === event && (
|
|
320
|
+
(h) => h.event === event && this.matchesMatcher(h.toolName, toolName),
|
|
278
321
|
)
|
|
279
322
|
|
|
323
|
+
// Stamped here rather than at each `executeX`: the cwd is a property of the
|
|
324
|
+
// engine, and every context this engine hands out needs it.
|
|
325
|
+
ctx.cwd = this.cwd
|
|
326
|
+
|
|
280
327
|
const result: HookResult = { allowed: true }
|
|
281
328
|
|
|
282
329
|
for (const hook of matching) {
|
|
@@ -22,6 +22,10 @@ export interface ImprovementReport {
|
|
|
22
22
|
noise: number
|
|
23
23
|
minEffect: number
|
|
24
24
|
verdict: ImprovementVerdict
|
|
25
|
+
/** ε:提交者事前写下的预期提升点数。缺席 = 该记录没有预登记。 */
|
|
26
|
+
predictedDelta?: number
|
|
27
|
+
/** 预测是否命中。与 predictedDelta 同时出现、同时缺席(JSON 序列化会丢掉 undefined 键)。 */
|
|
28
|
+
predictionHit?: boolean
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export interface ImprovementRecord extends ImprovementReport {
|
|
@@ -55,6 +59,7 @@ function stdDev(xs: number[]): number {
|
|
|
55
59
|
export function buildImprovementReport(
|
|
56
60
|
sample: SkillDeltaSample,
|
|
57
61
|
changeSet: string[],
|
|
62
|
+
predicted?: number,
|
|
58
63
|
): ImprovementReport {
|
|
59
64
|
const deltaMean = mean(sample.postScores) - mean(sample.baselineScores)
|
|
60
65
|
const noise = stdDev(sample.baselineScores)
|
|
@@ -70,6 +75,14 @@ export function buildImprovementReport(
|
|
|
70
75
|
noise,
|
|
71
76
|
minEffect,
|
|
72
77
|
verdict,
|
|
78
|
+
// 两个字段同生同灭:缺席预测必须**键不存在**,而不是 predictionHit: false。
|
|
79
|
+
// 理由**不是**「predictionHit: false 会被算进分母」—— 分母只认 `predictedDelta !== undefined`
|
|
80
|
+
//(只写 `predictionHit: false` 而不写 `predictedDelta` 会被整条忽略)。真正的风险是**反过来的半条**:
|
|
81
|
+
// 有 `predictedDelta` 而无 `predictionHit` ⇒ 该条进了分母,却永远不可能被算成命中
|
|
82
|
+
//(分子只认 `predictionHit === true`),等于一条静默的「未命中」。
|
|
83
|
+
...(predicted !== undefined
|
|
84
|
+
? { predictedDelta: predicted, predictionHit: predictionHit(predicted, deltaMean) }
|
|
85
|
+
: {}),
|
|
73
86
|
}
|
|
74
87
|
}
|
|
75
88
|
|
|
@@ -107,6 +120,34 @@ export function improvementSignalStrong(records: ImprovementRecord[]): boolean {
|
|
|
107
120
|
return records.length > 0 && lo > FALSE_POSITIVE_BASELINE
|
|
108
121
|
}
|
|
109
122
|
|
|
123
|
+
/**
|
|
124
|
+
* 预测命中:事前写下的点数被实际达到。缺席预测(undefined)不计入。
|
|
125
|
+
*
|
|
126
|
+
* 刻意**不叠加 `minEffect`** —— ε 是提交者自己写下的数,判据就是「达到没达到」;
|
|
127
|
+
* 再套一层统计阈值会让两个数打架,且使「命中」不可复算。
|
|
128
|
+
*/
|
|
129
|
+
export function predictionHit(predicted: number | undefined, deltaMean: number): boolean {
|
|
130
|
+
return predicted !== undefined && deltaMean >= predicted
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* ε 命中率。分母 = **有预测的记录数**(判据取 `predictedDelta !== undefined`),
|
|
135
|
+
* 复用既有 wilsonInterval。无预测的记录既不入分子也不入分母。
|
|
136
|
+
*/
|
|
137
|
+
export function predictionHitRate(records: ImprovementRecord[]): {
|
|
138
|
+
total: number
|
|
139
|
+
hits: number
|
|
140
|
+
rate: number
|
|
141
|
+
lo: number
|
|
142
|
+
hi: number
|
|
143
|
+
} {
|
|
144
|
+
const judged = records.filter((r) => r.predictedDelta !== undefined)
|
|
145
|
+
const total = judged.length
|
|
146
|
+
const hits = judged.filter((r) => r.predictionHit === true).length
|
|
147
|
+
const { lo, hi } = wilsonInterval(hits, total)
|
|
148
|
+
return { total, hits, rate: total === 0 ? 0 : hits / total, lo, hi }
|
|
149
|
+
}
|
|
150
|
+
|
|
110
151
|
// ── 台账 ──
|
|
111
152
|
|
|
112
153
|
export function improvementPath(): string {
|
package/src/core/paths.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* 突然失去隔离保护(隔离度只许增不许减)。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { realpathSync } from 'node:fs'
|
|
10
11
|
import { homedir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
+
import { basename, dirname, join } from 'node:path'
|
|
12
13
|
import { MIPHAM_DIR } from '../shared/constants.ts'
|
|
13
14
|
|
|
14
15
|
/** 只读兼容目录名。 */
|
|
@@ -33,6 +34,48 @@ export function worktreeRoots(cwd: string): string[] {
|
|
|
33
34
|
return [worktreeRoot(cwd), join(cwd, LEGACY_CLAUDE_DIR, 'worktrees')]
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 规范化 worktree 路径,两侧都过一遍才谈得上比较。
|
|
39
|
+
*
|
|
40
|
+
* 叶子不存在是常态(工作树已被删、或路径是模型编出来的),此时 `realpathSync`
|
|
41
|
+
* 会抛 —— 那就只规范父目录、最后一段按原样留着,否则「叶子没了」会被误读成
|
|
42
|
+
* 「拼法不同」(明明同一个路径,却因为 P 的拼法与 git 打印的不同而判成不在)。
|
|
43
|
+
*/
|
|
44
|
+
function canonicalWorktreePath(path: string): string {
|
|
45
|
+
const trimmed = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(trimmed)
|
|
48
|
+
} catch {
|
|
49
|
+
try {
|
|
50
|
+
return join(realpathSync(dirname(trimmed)), basename(trimmed))
|
|
51
|
+
} catch {
|
|
52
|
+
return trimmed
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `git worktree list --porcelain` 里是否**确实**列出了 `target` 这个工作树。
|
|
59
|
+
*
|
|
60
|
+
* 不能用 `output.includes(target)`:那是子串判定,本机实测(真 git,建出 `w1`
|
|
61
|
+
* 与 `w10`)它错在三个方向 ——
|
|
62
|
+
* - `.../w1` 命中 **`.../w10` 那一行**(前缀当成同一个)⇒ 不存在被判成存在。
|
|
63
|
+
* EnterWorktree 那侧因此连 `w1` 都建不出来:明明没有,它报 already exists;
|
|
64
|
+
* - 带尾斜杠的 `.../w1/` 一行都不命中 ⇒ 存在被判成 not found;
|
|
65
|
+
* - git 打印 **realpath 拼法**(`mktemp -d /tmp/x` 建的在 porcelain 里是
|
|
66
|
+
* `/private/tmp/x/...`)⇒ 别名拼法一头都命中不了,而 EnterWorktree 的成功
|
|
67
|
+
* 文案里印的正是它自己算出来的那个拼法,模型照抄回来必然吃 not found。
|
|
68
|
+
*
|
|
69
|
+
* 判据是**相等**(名字比对),不是包含 —— 工作树列表里列的就是工作树根。
|
|
70
|
+
*/
|
|
71
|
+
export function listsWorktree(output: string, target: string): boolean {
|
|
72
|
+
const want = canonicalWorktreePath(target)
|
|
73
|
+
return output
|
|
74
|
+
.split('\n')
|
|
75
|
+
.filter((line) => line.startsWith('worktree '))
|
|
76
|
+
.some((line) => canonicalWorktreePath(line.slice('worktree '.length).trim()) === want)
|
|
77
|
+
}
|
|
78
|
+
|
|
36
79
|
/**
|
|
37
80
|
* 在 `cwd` 中定位 worktree 标记,返回项目根与命中的标记。
|
|
38
81
|
* 不在任何 worktree 内时返回 null。
|