@miphamai/cli 0.82.0 → 0.83.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.82.0",
3
+ "version": "0.83.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -97,10 +97,24 @@ export function regressedAnchors(results: EvalResult[]): string[] {
97
97
 
98
98
  const SCORES_FILE = join(homedir(), '.mipham', 'crsi', 'eval-scores.jsonl')
99
99
 
100
+ /** 落盘的契约粒度投影 —— 只要 id/passed/role(EvalResult 的 description/detail 不落盘)。 */
101
+ export interface ContractResultRecord {
102
+ id: string
103
+ passed: boolean
104
+ role?: ContractRole
105
+ }
106
+
107
+ /** 一次评估的契约粒度快照:契约 id → 是否通过。 */
108
+ export type ContractSnapshot = Record<string, boolean>
109
+
110
+ function toContractResultRecord(r: EvalResult): ContractResultRecord {
111
+ return { id: r.id, passed: r.passed, ...(r.role ? { role: r.role } : {}) }
112
+ }
113
+
100
114
  /** 追加一次评估分数到 rewards 日志(按奖励函数名键控)。 */
101
115
  export function appendEvalScore(
102
116
  name: string,
103
- report: { score: number; passed: number; total: number },
117
+ report: { score: number; passed: number; total: number; results?: EvalResult[] },
104
118
  ): void {
105
119
  try {
106
120
  mkdirSync(join(homedir(), '.mipham', 'crsi'), { recursive: true })
@@ -112,6 +126,9 @@ export function appendEvalScore(
112
126
  score: report.score,
113
127
  passed: report.passed,
114
128
  total: report.total,
129
+ // 契约粒度(B1)。缺省不写该键:B1 之前落盘的旧记录没有它,
130
+ // 读取侧跳过 —— 这是向后兼容的承重判据,别改成 `results: []`。
131
+ ...(report.results ? { results: report.results.map(toContractResultRecord) } : {}),
115
132
  }) + '\n',
116
133
  'utf-8',
117
134
  )
@@ -135,6 +152,105 @@ export function getLastEvalScore(name: string): number | null {
135
152
  }
136
153
  }
137
154
 
155
+ /**
156
+ * 某奖励函数最近 n 次**按契约粒度**落盘的记录,新→旧。
157
+ *
158
+ * 只认带 `results` 的记录 —— B1 之前落盘的旧记录(只有聚合分数)被跳过,
159
+ * 于是调用方不必区分新旧形态。逐行容错:坏行跳过而不是让整条历史归零
160
+ * (`fixCache` 负责清理坏行)。
161
+ */
162
+ export function getContractHistory(name: string, n = 3): ContractSnapshot[] {
163
+ try {
164
+ if (!existsSync(SCORES_FILE)) return []
165
+ const lines = readFileSync(SCORES_FILE, 'utf-8').trim().split('\n').filter(Boolean)
166
+ const out: ContractSnapshot[] = []
167
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
168
+ let rec: { name?: string; results?: ContractResultRecord[] }
169
+ try {
170
+ rec = JSON.parse(lines[i]!) as { name?: string; results?: ContractResultRecord[] }
171
+ } catch {
172
+ continue
173
+ }
174
+ if (rec.name !== name || !Array.isArray(rec.results)) continue
175
+ const snap: ContractSnapshot = {}
176
+ for (const r of rec.results) {
177
+ if (typeof r?.id === 'string') snap[r.id] = r.passed === true
178
+ }
179
+ out.push(snap)
180
+ }
181
+ return out
182
+ } catch {
183
+ return []
184
+ }
185
+ }
186
+
187
+ export type ContractDelta = 'regressed' | 'fixed' | 'flaky' | 'new' | 'gone'
188
+
189
+ /**
190
+ * 纯函数:当前 run vs 历史 → 每条契约的变化。**只报变化**,未变化的契约不出现。
191
+ *
192
+ * delta 判据(`history` 新→旧):
193
+ * - 历史上没出现过 → `new`
194
+ * - 历史上 true/false 都出现过 → `flaky`(压过 regressed/fixed:抖动的契约
195
+ * 不该被报成「已修复」或「回归」)
196
+ * - 上次与本次相反 → `regressed`(上次 PASS→本次 FAIL)/ `fixed`
197
+ * - 本次没有但历史有 → `gone`
198
+ *
199
+ * 为什么 `flaky` 要压过相邻两次的比较:只比相邻两次会把一个每次都在翻的契约
200
+ * 误报成「真回归」,而那正是这个账本要区分开的东西。
201
+ */
202
+ export function diffContractHistory(
203
+ current: ContractResultRecord[],
204
+ history: ContractSnapshot[],
205
+ ): { id: string; delta: ContractDelta; role?: ContractRole }[] {
206
+ const out: { id: string; delta: ContractDelta; role?: ContractRole }[] = []
207
+ const seen = new Set<string>()
208
+ for (const c of current) {
209
+ seen.add(c.id)
210
+ const past = history.filter((h) => c.id in h).map((h) => h[c.id] === true)
211
+ let delta: ContractDelta
212
+ if (past.length === 0) delta = 'new'
213
+ else if (past.includes(true) && past.includes(false)) delta = 'flaky'
214
+ else if (past[0] === !c.passed) delta = c.passed ? 'fixed' : 'regressed'
215
+ else continue // 未变化
216
+ out.push({ id: c.id, delta, ...(c.role ? { role: c.role } : {}) })
217
+ }
218
+ for (const h of history) {
219
+ for (const id of Object.keys(h)) {
220
+ if (seen.has(id)) continue
221
+ seen.add(id)
222
+ out.push({ id, delta: 'gone' })
223
+ }
224
+ }
225
+ return out
226
+ }
227
+
228
+ /**
229
+ * 把 diffContractHistory 的结果渲染成展示行。纯函数——不做 I/O、不读时钟。
230
+ * 返回空数组表示「无变化」,调用方据此决定是否打印标题。
231
+ */
232
+ export function renderContractDiff(
233
+ deltas: { id: string; delta: ContractDelta; role?: ContractRole }[],
234
+ ): string[] {
235
+ const text: Record<ContractDelta, string> = {
236
+ regressed: '上次 PASS,本次 FAIL(回归)',
237
+ fixed: '上次 FAIL,本次 PASS(已修复)',
238
+ flaky: '近几次结果不一致(抖动)',
239
+ new: '本次新增的契约',
240
+ gone: '本次未出现(已移出契约集)',
241
+ }
242
+ const icon: Record<ContractDelta, string> = {
243
+ regressed: '❌',
244
+ fixed: '✅',
245
+ flaky: '⚠️',
246
+ new: '🆕',
247
+ gone: '➖',
248
+ }
249
+ return deltas.map(
250
+ (d) => `${icon[d.delta]} ${d.id} ← ${text[d.delta]}${d.role ? ` \`${d.role}\`` : ''}`,
251
+ )
252
+ }
253
+
138
254
  // ── Harness ──
139
255
 
140
256
  /** 构建隔离组件,避免读用户 ~/.mipham 运行时状态。 */
@@ -26,6 +26,13 @@ export interface ImprovementReport {
26
26
  predictedDelta?: number
27
27
  /** 预测是否命中。与 predictedDelta 同时出现、同时缺席(JSON 序列化会丢掉 undefined 键)。 */
28
28
  predictionHit?: boolean
29
+ /**
30
+ * B2 代价维:与分数数组逐项对齐的前/后耗时。**只记录,不进任何门禁** ——
31
+ * `verdict` / `deltaMean` / `minEffect` 一律不看这两个字段。
32
+ * 缺席(而非空数组)= 该记录早于代价维落地,或该样本未记代价。
33
+ */
34
+ baselineDurations?: number[]
35
+ postDurations?: number[]
29
36
  }
30
37
 
31
38
  export interface ImprovementRecord extends ImprovementReport {
@@ -83,6 +90,11 @@ export function buildImprovementReport(
83
90
  ...(predicted !== undefined
84
91
  ? { predictedDelta: predicted, predictionHit: predictionHit(predicted, deltaMean) }
85
92
  : {}),
93
+ // 代价维:**两条同生同灭**,与上面 ε 那条同理 —— 只写一半(有 baselineDurations
94
+ // 而无 postDurations)会让「代价」这件事在记录里既非有也非无,读侧无从判断。
95
+ ...(sample.baselineDurations !== undefined && sample.postDurations !== undefined
96
+ ? { baselineDurations: sample.baselineDurations, postDurations: sample.postDurations }
97
+ : {}),
86
98
  }
87
99
  }
88
100
 
@@ -148,6 +160,24 @@ export function predictionHitRate(records: ImprovementRecord[]): {
148
160
  return { total, hits, rate: total === 0 ? 0 : hits / total, lo, hi }
149
161
  }
150
162
 
163
+ /**
164
+ * 代价维的只读展示(B2):前/后均值一行。两个耗时数组缺席 → null,调用方据此整行不打印。
165
+ *
166
+ * **刻意不下结论**:倍数只是描述,不是判据。把「代价过高」变成 verdict 的一部分需要
167
+ * 样本量支撑,而当前 k 默认 3、`minEffect` 已经要 `max(20, 2×噪声)` —— 再塞一个维度
168
+ * 只会把统计问题变得更糟。所以这里只回答「花了多少」,不回答「值不值」。
169
+ *
170
+ * 基线均值为 0 时**不给倍数**:那个比值是 ∞(或 0/0 的 NaN),打出来是假读数。
171
+ */
172
+ export function formatCostLine(report: ImprovementReport): string | null {
173
+ const { baselineDurations: before_, postDurations: after_ } = report
174
+ if (before_ === undefined || after_ === undefined) return null
175
+ const before = Math.round(mean(before_))
176
+ const after = Math.round(mean(after_))
177
+ const line = `⏱️ 代价: 均值 ${before}ms → ${after}ms`
178
+ return before > 0 ? `${line}(×${(after / before).toFixed(1)})` : line
179
+ }
180
+
151
181
  // ── 台账 ──
152
182
 
153
183
  export function improvementPath(): string {
@@ -68,6 +68,8 @@ export interface TaskPerformanceReport {
68
68
  score: number
69
69
  results: TaskPerformanceResult[]
70
70
  failures: string[]
71
+ /** B2 代价维:整轮(LLM 生成 + 冻结测试判定)的墙钟耗时。只记录,不参与任何判定。 */
72
+ durationMs: number
71
73
  }
72
74
 
73
75
  /** 剥掉 LLM 可能包裹的 markdown 代码块(```ts ... ```),拿到裸代码。 */
@@ -101,6 +103,7 @@ export async function runTaskPerformance(
101
103
  const wanted = opts?.skill?.name
102
104
  const tasks = loadPerformanceTasks().filter((t) => (t.skill ?? undefined) === wanted)
103
105
  const results: TaskPerformanceResult[] = []
106
+ const startedAt = Date.now()
104
107
  for (const task of tasks) {
105
108
  const code = await collectGeneratedCode(llm, task.prompt, opts?.skill?.text)
106
109
  if (!code) {
@@ -127,6 +130,7 @@ export async function runTaskPerformance(
127
130
  score: results.length > 0 ? Math.round((passed / results.length) * 100) : 100,
128
131
  results,
129
132
  failures: results.filter((r) => !r.passed).map((r) => r.id),
133
+ durationMs: Date.now() - startedAt,
130
134
  }
131
135
  }
132
136
 
@@ -210,6 +214,13 @@ export interface SkillDeltaSample {
210
214
  skillName: string
211
215
  baselineScores: number[]
212
216
  postScores: number[]
217
+ /**
218
+ * B2 代价维:与分数数组**逐项对齐**的耗时(第 i 项就是产出第 i 个分数的那次采样)。
219
+ * 缺席 = 该样本未记代价(旧记录 / 旧调用点),读侧须按「缺席」处理、不得当成 0。
220
+ * 只记录,不参与改进判定 —— 样本量 k=3 时再加一个维度只会让统计问题更糟。
221
+ */
222
+ baselineDurations?: number[]
223
+ postDurations?: number[]
213
224
  }
214
225
 
215
226
  /**
@@ -227,18 +238,28 @@ export async function measureSkillDeltaRepeated(
227
238
  const k = opts?.k ?? 3
228
239
  const baselineScores: number[] = []
229
240
  const postScores: number[] = []
241
+ const baselineDurations: number[] = []
242
+ const postDurations: number[] = []
230
243
  for (let i = 0; i < k; i++) {
231
244
  const r = await runTaskPerformance(llm, {
232
245
  skill: { name: resolved.skillName, text: resolved.baselineText },
233
246
  })
234
247
  baselineScores.push(r.score)
248
+ baselineDurations.push(r.durationMs)
235
249
  }
236
250
  for (let i = 0; i < k; i++) {
237
251
  const r = await runTaskPerformance(llm, {
238
252
  skill: { name: resolved.skillName, text: resolved.postText },
239
253
  })
240
254
  postScores.push(r.score)
255
+ postDurations.push(r.durationMs)
241
256
  }
242
257
 
243
- return { skillName: resolved.skillName, baselineScores, postScores }
258
+ return {
259
+ skillName: resolved.skillName,
260
+ baselineScores,
261
+ postScores,
262
+ baselineDurations,
263
+ postDurations,
264
+ }
244
265
  }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.82.0' as const
12
+ export const PACKAGE_VERSION = '0.83.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -37,7 +37,13 @@ import {
37
37
  MANAGED_RULES_FILE,
38
38
  } from '../core/crsi-producer'
39
39
  import { prefilterProposal } from '../core/proposal-guard'
40
- import { runEval, appendEvalScore } from '../core/eval-harness'
40
+ import {
41
+ runEval,
42
+ appendEvalScore,
43
+ getContractHistory,
44
+ diffContractHistory,
45
+ renderContractDiff,
46
+ } from '../core/eval-harness'
41
47
  import { listRewardFns } from '../core/reward-fn'
42
48
  import {
43
49
  runTaskPerformance,
@@ -47,6 +53,7 @@ import {
47
53
  import { randomUUID } from 'node:crypto'
48
54
  import {
49
55
  buildImprovementReport,
56
+ formatCostLine,
50
57
  appendImprovement,
51
58
  readImprovements,
52
59
  improvementRate,
@@ -901,9 +908,12 @@ const crsiModifyCmd: CommandHandler = async (ctx, args) => {
901
908
  ? 'regressed ⚠️'
902
909
  : 'inconclusive'
903
910
  const sign = report.deltaMean >= 0 ? '+' : ''
911
+ // B2 代价维:只展示、不进判定(`formatCostLine` 缺席时返回 null ⇒ 整行不打)。
912
+ const costLine = formatCostLine(report)
904
913
  improvementLine =
905
914
  `\n📊 改进判定: ${label} (delta ${sign}${report.deltaMean.toFixed(1)}, 噪声 ${report.noise.toFixed(1)}, 阈值 ${report.minEffect.toFixed(1)})` +
906
915
  `\n 改进率: ${rate.improved}/${rate.total} (${(rate.rate * 100).toFixed(0)}%, Wilson 95% [${(rate.lo * 100).toFixed(0)}%, ${(rate.hi * 100).toFixed(0)}%])` +
916
+ (costLine ? `\n${costLine}` : '') +
907
917
  (report.verdict === 'regressed' ? '\n ⚠️ 任务表现倒退:--approve 将被拒绝。' : '')
908
918
  }
909
919
  } catch {
@@ -1009,6 +1019,10 @@ const crsiProposeCmd: CommandHandler = async (ctx, args) => {
1009
1019
  `\n🎯 ε 预测命中: ${report.predictionHit ? '命中 ✅' : '未命中 ⚠️'}` +
1010
1020
  `(预测 ${report.predictedDelta},实际 delta ${report.deltaMean.toFixed(1)})`
1011
1021
  }
1022
+ // B2 代价维:**与手工路径同一行读数**,两条渲染路径都接(只接一条即本仓库记过的
1023
+ // 「局部正确全局遗漏」)。ε 行可以有、代价行可以没有,故各自独立追加。
1024
+ const costLine = formatCostLine(report)
1025
+ if (costLine) predictionLine += `\n${costLine}`
1012
1026
  }
1013
1027
  } catch {
1014
1028
  // 测量失败(LLM 不可用等)不阻断提案流程 —— 与手工路径 :889 的处置一致。
@@ -1132,14 +1146,26 @@ const crsiEvalCmd: CommandHandler = async (ctx, args) => {
1132
1146
  content: `❌ 未知 reward: ${rewardName}。可用: ${fns.map((f) => f.name).join(', ')}`,
1133
1147
  }
1134
1148
  }
1149
+ // 先读后写:比较基准是「上一次已落盘的态」,不依赖刚写进去那条排第几。
1150
+ const prev = getContractHistory(fn.name)
1135
1151
  const report = await fn.evaluate()
1152
+ const deltaLines = report.results
1153
+ ? renderContractDiff(diffContractHistory(report.results, prev))
1154
+ : []
1136
1155
  appendEvalScore(fn.name, report)
1137
1156
  return {
1138
- content: `得分 **${report.score}/100** (${report.passed}/${report.total})\n失败: ${report.failures.join(', ') || '无'}`,
1157
+ content: [
1158
+ `得分 **${report.score}/100** (${report.passed}/${report.total})`,
1159
+ `失败: ${report.failures.join(', ') || '无'}`,
1160
+ ...(deltaLines.length > 0 ? ['', '### 与上次相比', ...deltaLines] : []),
1161
+ ].join('\n'),
1139
1162
  }
1140
1163
  }
1141
1164
 
1165
+ // 先读后写(同上):基准是上一次落盘的态。
1166
+ const prevSnapshot = getContractHistory('mechanism-sentinel')
1142
1167
  const report = runEval()
1168
+ const deltaLines = renderContractDiff(diffContractHistory(report.results, prevSnapshot))
1143
1169
  appendEvalScore('mechanism-sentinel', report)
1144
1170
 
1145
1171
  const lines: string[] = ['## 🧪 CRSI Eval Harness', '']
@@ -1154,6 +1180,11 @@ const crsiEvalCmd: CommandHandler = async (ctx, args) => {
1154
1180
  if (report.failures.length > 0) {
1155
1181
  lines.push('', `❌ 失败任务: ${report.failures.join(', ')}`)
1156
1182
  }
1183
+ // 只读展示:账本按契约粒度落盘后,才能回答「是哪条契约翻的」。
1184
+ // 这里不做任何自动决策——闸门仍只看 regressedAnchors / score。
1185
+ if (deltaLines.length > 0) {
1186
+ lines.push('', '### 与上次相比', ...deltaLines)
1187
+ }
1157
1188
 
1158
1189
  // 奖励函数注册表(reward function = policy→feedback 抽象可见)
1159
1190
  // 传 llm 列出完整注册表(task-performance 需 llm 才能跑,但构造它零 LLM 调用)。