@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.
Files changed (54) hide show
  1. package/bin/mipham.ts +35 -1
  2. package/package.json +1 -1
  3. package/src/agent/message-bus.ts +10 -3
  4. package/src/agent/sub-agent.ts +60 -12
  5. package/src/agent/types.ts +14 -1
  6. package/src/config/credential-crypto.ts +28 -5
  7. package/src/config/defaults.ts +18 -10
  8. package/src/config/keys-manager.ts +7 -1
  9. package/src/config/loader.ts +202 -63
  10. package/src/core/credential-masker/output-scrub.ts +16 -2
  11. package/src/core/crsi-modify.ts +34 -1
  12. package/src/core/crsi-producer.ts +77 -7
  13. package/src/core/crsi-sandbox.ts +65 -0
  14. package/src/core/engine.ts +7 -2
  15. package/src/core/eval-harness.ts +77 -4
  16. package/src/core/hooks-executor.ts +30 -2
  17. package/src/core/hooks.ts +51 -4
  18. package/src/core/improvement-track.ts +41 -0
  19. package/src/core/paths.ts +44 -1
  20. package/src/core/permission-config.ts +146 -14
  21. package/src/core/permission-rules.ts +17 -2
  22. package/src/core/permission.ts +81 -13
  23. package/src/core/rules-loader.ts +35 -5
  24. package/src/core/session-log.ts +5 -1
  25. package/src/core/workspace-trust.ts +42 -4
  26. package/src/daemon/auth.ts +15 -14
  27. package/src/daemon/engine-capabilities.ts +12 -2
  28. package/src/daemon/remote-engine.ts +9 -4
  29. package/src/daemon/server.ts +29 -1
  30. package/src/i18n-core/locales/en-US.json +12 -8
  31. package/src/i18n-core/locales/zh-CN.json +12 -8
  32. package/src/index.tsx +44 -17
  33. package/src/mcp/client.ts +24 -0
  34. package/src/mcp/http-transport.ts +35 -3
  35. package/src/plugin/plugin-manager.ts +13 -2
  36. package/src/providers/anthropic.ts +48 -11
  37. package/src/security/gate.ts +18 -0
  38. package/src/security/path.ts +19 -1
  39. package/src/shared/arg-validation.ts +37 -2
  40. package/src/shared/package-info.ts +1 -1
  41. package/src/shared/sanitize.ts +27 -2
  42. package/src/shared/types.ts +8 -0
  43. package/src/shared/update.ts +22 -5
  44. package/src/tools/agent/agent.ts +3 -0
  45. package/src/tools/exec/bash.ts +106 -6
  46. package/src/tools/exec/enter-worktree.ts +9 -3
  47. package/src/tools/exec/exit-worktree.ts +6 -3
  48. package/src/tools/exec/git.ts +76 -1
  49. package/src/tools/file/glob.ts +19 -3
  50. package/src/tools/file/grep.ts +33 -3
  51. package/src/tools/index.ts +12 -4
  52. package/src/ui/app.tsx +47 -11
  53. package/src/ui/commands.ts +205 -30
  54. package/src/workflow/primitives/agent.ts +4 -0
@@ -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 {
@@ -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
  )
@@ -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 { isProtectedPath, validateBlastRadius, PROTECTED_CRITICAL_FILES } from './crsi-sandbox'
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({ id, description: `受保护路径被拒: ${path}`, passed: isProtectedPath(path) })
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 走集中清单(门保护面单一真源),target 已在上方循环内联。
424
+ // 角色标注:anchor 由契约**定义处内联的标记**派生(真源),
425
+ // ANCHOR_CONTRACT_IDS 退为独立声明 —— 两者由 anchor-contract-wiring 守卫两向比对。
353
426
  for (const r of results) {
354
- if (ANCHOR_CONTRACT_IDS.has(r.id)) r.role = 'anchor'
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, process.cwd()))
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
- return this.runHooks('Stop', undefined, ctx)
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', undefined, ctx)
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', undefined, ctx)
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 && (!toolName || !h.toolName || h.toolName === toolName),
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。