oh-my-knowledge 0.35.0 → 0.37.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 (90) hide show
  1. package/README.md +5 -3
  2. package/README.zh.md +5 -3
  3. package/dist/assets/agent-skills/omk/SKILL.md +4 -2
  4. package/dist/assets/agent-skills/omk/references/commands.md +121 -2
  5. package/dist/authoring/evolver.js +1 -1
  6. package/dist/cli/commands/doctor.js +21 -5
  7. package/dist/cli/commands/eval/index.js +7 -6
  8. package/dist/cli/commands/evolve.d.ts +11 -0
  9. package/dist/cli/commands/evolve.js +105 -5
  10. package/dist/cli/commands/list.d.ts +25 -0
  11. package/dist/cli/commands/list.js +118 -0
  12. package/dist/cli/commands/promote.d.ts +24 -0
  13. package/dist/cli/commands/promote.js +158 -0
  14. package/dist/cli/commands/rollback.d.ts +20 -0
  15. package/dist/cli/commands/rollback.js +95 -0
  16. package/dist/cli/commands/sample.d.ts +1 -0
  17. package/dist/cli/commands/sample.js +10 -4
  18. package/dist/cli/lib/cell-format.d.ts +17 -0
  19. package/dist/cli/lib/cell-format.js +19 -0
  20. package/dist/cli/lib/cmd-flags.d.ts +1 -0
  21. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  22. package/dist/cli/lib/i18n-dict/common.js +8 -0
  23. package/dist/cli/lib/i18n-dict/list.d.ts +3 -0
  24. package/dist/cli/lib/i18n-dict/list.js +36 -0
  25. package/dist/cli/lib/i18n-dict/promote.d.ts +3 -0
  26. package/dist/cli/lib/i18n-dict/promote.js +54 -0
  27. package/dist/cli/lib/i18n-dict/rollback.d.ts +3 -0
  28. package/dist/cli/lib/i18n-dict/rollback.js +22 -0
  29. package/dist/cli/lib/i18n-dict.d.ts +4 -1
  30. package/dist/cli/lib/i18n-dict.js +6 -0
  31. package/dist/cli/lib/progress.d.ts +3 -0
  32. package/dist/cli/lib/progress.js +22 -0
  33. package/dist/cli/lib/run-tally.js +1 -1
  34. package/dist/cli/lib/shared.js +1 -1
  35. package/dist/cli/lib/source-probe.d.ts +15 -0
  36. package/dist/cli/lib/source-probe.js +129 -0
  37. package/dist/doctor/endpoint-rule.d.ts +60 -0
  38. package/dist/doctor/endpoint-rule.js +381 -0
  39. package/dist/doctor/health/load-custom-dimensions.d.ts +6 -0
  40. package/dist/doctor/health/load-custom-dimensions.js +43 -3
  41. package/dist/doctor/index.js +9 -2
  42. package/dist/eval-core/evaluation-reporting.d.ts +1 -0
  43. package/dist/eval-core/evaluation-reporting.js +6 -4
  44. package/dist/eval-workflows/batch-evaluation-workflow.js +3 -2
  45. package/dist/eval-workflows/run-evaluation.js +2 -2
  46. package/dist/executors/codex-cli.js +5 -1
  47. package/dist/inputs/skill-loader.js +6 -3
  48. package/dist/managed/index.d.ts +2 -0
  49. package/dist/managed/index.js +2 -0
  50. package/dist/managed/list-view.d.ts +58 -0
  51. package/dist/managed/list-view.js +70 -0
  52. package/dist/managed/promote-gate.d.ts +40 -0
  53. package/dist/managed/promote-gate.js +37 -0
  54. package/dist/managed/store.d.ts +25 -2
  55. package/dist/managed/store.js +137 -11
  56. package/dist/observability/experience.d.ts +2 -0
  57. package/dist/observability/experience.js +31 -2
  58. package/dist/observability/inbox.js +31 -5
  59. package/dist/observability/review-state.js +22 -11
  60. package/dist/observability/soft-standards/llm-extractor.js +3 -3
  61. package/dist/observability/soft-standards/skill-standards-store.d.ts +1 -0
  62. package/dist/observability/soft-standards/skill-standards-store.js +77 -14
  63. package/dist/observability/soft-standards/types.d.ts +2 -2
  64. package/dist/renderer/doctor-detail-renderer.d.ts +9 -0
  65. package/dist/renderer/doctor-detail-renderer.js +114 -0
  66. package/dist/renderer/html-renderer.d.ts +3 -2
  67. package/dist/renderer/html-renderer.js +102 -102
  68. package/dist/renderer/icons.d.ts +29 -0
  69. package/dist/renderer/icons.js +66 -0
  70. package/dist/renderer/layout.js +81 -68
  71. package/dist/renderer/observation-inbox/styles.d.ts +1 -1
  72. package/dist/renderer/observation-inbox/styles.js +53 -53
  73. package/dist/renderer/report-shell.d.ts +77 -0
  74. package/dist/renderer/report-shell.js +223 -0
  75. package/dist/renderer/skill-detail-renderer.js +147 -352
  76. package/dist/renderer/skill-health-renderer.js +50 -73
  77. package/dist/renderer/skill-list-renderer.js +358 -306
  78. package/dist/renderer/summary.js +272 -162
  79. package/dist/renderer/test-view.d.ts +4 -3
  80. package/dist/renderer/test-view.js +386 -135
  81. package/dist/server/report-server.js +142 -28
  82. package/dist/server/report-store.d.ts +1 -1
  83. package/dist/server/report-store.js +16 -15
  84. package/dist/server/skill-index.js +5 -4
  85. package/dist/types/doctor.d.ts +24 -2
  86. package/dist/types/doctor.js +1 -1
  87. package/dist/types/managed.d.ts +17 -3
  88. package/dist/types/observability.d.ts +6 -6
  89. package/dist/types/report.d.ts +9 -4
  90. package/package.json +1 -1
@@ -0,0 +1,381 @@
1
+ /**
2
+ * endpoint-rule — 接口驱动的自定义检查维度。
3
+ *
4
+ * 普通自定义维度走 LLM health composer(promptSection 喂 prompt);带 `endpoint`
5
+ * 的维度则注册成独立 DoctorRule:doctor 运行时把 skill 完整快照 POST 给用户的
6
+ * 接口,接口按协议返回判定结果。适合"调外部服务做深度审查"(eg. 安全风险审查)
7
+ * 这类靠 prompt 表达不了、需要真实逻辑/模型的场景。
8
+ *
9
+ * 请求协议(doctor → endpoint):
10
+ * POST <endpoint> Content-Type: application/json
11
+ * {
12
+ * "dimensionId": "deep-security-audit",
13
+ * "params": { ... }, // YAML 里用户透传的自定义参数
14
+ * "skill": {
15
+ * "name": "my-skill",
16
+ * "content": "SKILL.md 全文", // file-skill / directory-skill 主文件
17
+ * "skillRoot": "/abs/path" | null, // git / inline 来源可能为 null
18
+ * "ref": "abc1234" | null, // git 来源的 commit
19
+ * "files": { "references/x.md": "...", "scripts/y.sh": "..." } // 子文件快照
20
+ * }
21
+ * }
22
+ *
23
+ * 响应协议(endpoint → doctor):
24
+ * { "status": "pass" | "warn" | "fail", "message": "...", "hint"?: "...", "detail"?: {...} }
25
+ */
26
+ import { existsSync, readFileSync, readdirSync, lstatSync, openSync, readSync, closeSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ const DEFAULT_MAX_FILE_BYTES = 200 * 1024;
29
+ const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
30
+ /** 二进制 / 大文件不可读时跳过;只收文本。简单按扩展名 + 内容嗅探。 */
31
+ const SKIP_EXTENSIONS = new Set([
32
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf',
33
+ '.zip', '.gz', '.tar', '.tgz', '.exe', '.bin', '.so', '.dylib',
34
+ '.woff', '.woff2', '.ttf', '.mp4', '.mov', '.mp3', '.wav',
35
+ ]);
36
+ function extOf(name) {
37
+ const i = name.lastIndexOf('.');
38
+ return i >= 0 ? name.slice(i).toLowerCase() : '';
39
+ }
40
+ /** 收集 skillRoot 下的子文件内容(排除主 SKILL.md / 隐藏文件 / node_modules)。
41
+ * 受 maxFileBytes / maxTotalBytes 双重限制,避免 payload 过大。 */
42
+ function collectFiles(skillRoot, maxFileBytes, maxTotalBytes) {
43
+ const files = {};
44
+ if (!skillRoot || !existsSync(skillRoot))
45
+ return files;
46
+ let total = 0;
47
+ const walk = (dir, rel, depth) => {
48
+ if (depth > 3 || total >= maxTotalBytes)
49
+ return;
50
+ let entries = [];
51
+ try {
52
+ entries = readdirSync(dir);
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ for (const entry of entries) {
58
+ if (total >= maxTotalBytes)
59
+ return;
60
+ if (entry.startsWith('.'))
61
+ continue;
62
+ if (entry === 'node_modules')
63
+ continue;
64
+ if (entry === 'SKILL.md' && rel === '')
65
+ continue;
66
+ const full = join(dir, entry);
67
+ const relPath = rel ? `${rel}/${entry}` : entry;
68
+ let st;
69
+ // lstat(不跟随 symlink):跳过符号链接,防止 skillRoot 下的 link 指向 root 外
70
+ // (如 /etc/passwd)被读取并随 payload 外发。
71
+ try {
72
+ st = lstatSync(full);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ if (st.isSymbolicLink())
78
+ continue;
79
+ if (st.isDirectory()) {
80
+ walk(full, relPath, depth + 1);
81
+ }
82
+ else if (st.isFile()) {
83
+ if (SKIP_EXTENSIONS.has(extOf(entry)))
84
+ continue;
85
+ let buf;
86
+ if (st.size > maxFileBytes) {
87
+ // 大文件守卫:不整文件读入(超大文件会撑内存,超 Buffer 上限的 throw
88
+ // 还会被 catch 静默跳过),只读前 maxFileBytes + 1 字节,NUL 嗅探与
89
+ // 截断都在这个前缀上做。
90
+ let fd;
91
+ try {
92
+ fd = openSync(full, 'r');
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ try {
98
+ const head = Buffer.allocUnsafe(maxFileBytes + 1);
99
+ const n = readSync(fd, head, 0, head.length, 0);
100
+ buf = head.subarray(0, n);
101
+ }
102
+ catch {
103
+ continue;
104
+ }
105
+ finally {
106
+ closeSync(fd);
107
+ }
108
+ }
109
+ else {
110
+ try {
111
+ buf = readFileSync(full);
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ }
117
+ // 内容嗅探:含 NUL 字节 → 二进制,跳过(扩展名漏网的 binary 兜底)。
118
+ if (buf.includes(0))
119
+ continue;
120
+ // 按字节截断(中文等多字节内容也不会冲穿上限);单文件受 maxFileBytes 与
121
+ // 剩余总预算双重收口,超限只收前缀,toString 会把尾部可能被切断的半个
122
+ // UTF-8 字符替换成 U+FFFD。total 按实际写入的字节数累计(含截断标记与
123
+ // U+FFFD 展开),所以 maxTotalBytes 至多被末文件冲破十几个字节(常数级)。
124
+ const cap = Math.min(maxFileBytes, maxTotalBytes - total);
125
+ const over = buf.length > cap;
126
+ const slice = over ? buf.subarray(0, cap) : buf;
127
+ const text = slice.toString('utf-8') + (over ? '\n…[truncated]' : '');
128
+ files[relPath] = text;
129
+ total += Buffer.byteLength(text, 'utf-8');
130
+ }
131
+ }
132
+ };
133
+ walk(skillRoot, '', 0);
134
+ return files;
135
+ }
136
+ function isValidStatus(s) {
137
+ return s === 'pass' || s === 'warn' || s === 'fail';
138
+ }
139
+ /** ::ffff:a.b.c.d / 规范化后的 ::ffff:hhhh:hhhh 还原成点分 IPv4;无法解析返回 null。
140
+ * WHATWG URL 会把 ::ffff:127.0.0.1 规范成 hex 形态 ::ffff:7f00:1。 */
141
+ function mappedToIPv4(suffix) {
142
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(suffix))
143
+ return suffix;
144
+ const hx = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(suffix);
145
+ if (!hx)
146
+ return null;
147
+ const hi = parseInt(hx[1], 16);
148
+ const lo = parseInt(hx[2], 16);
149
+ return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
150
+ }
151
+ /** 私网/本机 hostname 判定(SSRF 防护用)。只做字面 hostname 检查的
152
+ * defense-in-depth:不做 DNS 解析,公网域名解析到内网(DNS rebinding)不在
153
+ * 防护范围。WHATWG URL 会把 0x7f.0.0.1 / 0 之类写法规整成点分十进制,所以
154
+ * IPv4 直接按规范化后的 hostname 判断即可。 */
155
+ function isPrivateHostname(hostname) {
156
+ // 转小写、去 IPv6 字面量方括号([::1] → ::1)、去 FQDN 尾点(localhost. / foo.local.)。
157
+ let h = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
158
+ if (h === 'localhost' || h.endsWith('.local'))
159
+ return true;
160
+ if (h.includes(':')) {
161
+ // IPv6 字面量。
162
+ if (h === '::1' || h === '::')
163
+ return true; // loopback / unspecified
164
+ if (/^f[cd]/.test(h))
165
+ return true; // fc00::/7 ULA
166
+ if (/^fe[89ab]/.test(h))
167
+ return true; // fe80::/10 link-local
168
+ // IPv4-mapped(::ffff:…)归并到点分 IPv4 再判;无法解析的保守拒绝。
169
+ const mapped = /^::ffff:(.+)$/.exec(h);
170
+ if (!mapped)
171
+ return false;
172
+ const dotted = mappedToIPv4(mapped[1]);
173
+ if (dotted == null)
174
+ return true;
175
+ h = dotted;
176
+ }
177
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(h);
178
+ if (!m)
179
+ return false;
180
+ const a = Number(m[1]);
181
+ const b = Number(m[2]);
182
+ if (a === 0)
183
+ return true; // 0.0.0.0/8(含 0.0.0.0、http://0/)
184
+ if (a === 127)
185
+ return true; // 127.0.0.0/8 loopback
186
+ if (a === 10)
187
+ return true; // 10.0.0.0/8
188
+ if (a === 172 && b >= 16 && b <= 31)
189
+ return true; // 172.16.0.0/12
190
+ if (a === 192 && b === 168)
191
+ return true; // 192.168.0.0/16
192
+ if (a === 169 && b === 254)
193
+ return true; // 169.254.0.0/16(含云 metadata 169.254.169.254)
194
+ return false;
195
+ }
196
+ /** outcome 自由文本/JSON 字段的统一上限(字符),与协议违规路径的 received 截断对齐。 */
197
+ const MAX_OUTCOME_CHARS = 2000;
198
+ /** 响应体声明长度(Content-Length)上限(字节):超过即拒读,避免超大 body 撑爆内存。
199
+ * 注意只挡声明了 Content-Length 的情况;分块且不声明长度的响应仍只受 timeoutMs 墙钟约束。 */
200
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
201
+ function clampText(s) {
202
+ return s.length > MAX_OUTCOME_CHARS ? `${s.slice(0, MAX_OUTCOME_CHARS)}…[truncated]` : s;
203
+ }
204
+ /** detail 先序列化判长:超限替换成 { truncated: true, preview } 结构,
205
+ * 保证仍是合法 JSON 值且不会无界塞进 report。 */
206
+ function clampDetail(detail) {
207
+ const json = JSON.stringify(detail) ?? '';
208
+ if (json.length <= MAX_OUTCOME_CHARS)
209
+ return detail;
210
+ return { truncated: true, preview: json.slice(0, MAX_OUTCOME_CHARS) };
211
+ }
212
+ /**
213
+ * 把一个 endpoint spec 编译成 DoctorRule。check() 内组装 skill 快照、POST、
214
+ * 校验响应协议并映射成 DoctorRuleCheckOutcome。
215
+ *
216
+ * 所有失败(网络错误 / 非 2xx / 非法 JSON / 协议字段缺失)都返回 status='fail',
217
+ * 让用户立刻看到接口侧的问题,而不是静默放行。
218
+ */
219
+ export function makeEndpointRule(spec, fetchFn = fetch) {
220
+ return {
221
+ id: spec.id,
222
+ severity: spec.severity,
223
+ // 自定义 key 不在 DOCTOR_MESSAGES 字典里,renderer 会 fallback 到 ruleId(=spec.id)。
224
+ labelKey: `cli.doctor.endpoint.${spec.id}`,
225
+ // 网络检查,与 health composer 同档:默认跑,--static-only 跳过。
226
+ external: true,
227
+ async check(ctx) {
228
+ // SSRF 防护(defense-in-depth):endpoint 会收到 skill 完整快照,且响应
229
+ // 原样回填进报告(response oracle),所以组装请求前先校验 scheme 与 host,
230
+ // 私网/本机地址默认拒绝(云 metadata 169.254.169.254、内网主机等)。
231
+ // 只做字面校验、不做 DNS 解析:公网域名解析到内网(DNS rebinding)不在
232
+ // 防护范围。
233
+ let endpointUrl;
234
+ try {
235
+ endpointUrl = new URL(spec.endpoint);
236
+ }
237
+ catch {
238
+ return {
239
+ status: 'fail',
240
+ message: failMsg(ctx, spec, `endpoint 不是合法 URL:${spec.endpoint}`, `endpoint is not a valid URL: ${spec.endpoint}`),
241
+ detail: { endpoint: spec.endpoint },
242
+ };
243
+ }
244
+ if (endpointUrl.protocol !== 'http:' && endpointUrl.protocol !== 'https:') {
245
+ return {
246
+ status: 'fail',
247
+ message: failMsg(ctx, spec, `endpoint 协议必须是 http/https,实际是 ${endpointUrl.protocol}`, `endpoint protocol must be http/https, got ${endpointUrl.protocol}`),
248
+ detail: { endpoint: spec.endpoint, protocol: endpointUrl.protocol },
249
+ };
250
+ }
251
+ if (!spec.allowPrivateHost && isPrivateHostname(endpointUrl.hostname)) {
252
+ return {
253
+ status: 'fail',
254
+ message: failMsg(ctx, spec, `endpoint 指向私网/本机地址(${endpointUrl.hostname}),默认拒绝以防 SSRF(skill 快照会被外发、响应会回填进报告);确认该内网服务可信后,可在维度配置加 allowPrivateHost: true 放行`, `endpoint points to a private/loopback host (${endpointUrl.hostname}); refused by default to prevent SSRF (the skill snapshot is sent out and the response is echoed into the report). Set allowPrivateHost: true in the dimension config if this internal service is trusted`),
255
+ detail: { endpoint: spec.endpoint, hostname: endpointUrl.hostname },
256
+ };
257
+ }
258
+ const artifact = ctx.artifact;
259
+ const content = artifact.content ?? '';
260
+ const skillName = artifact.name.replace(/\.md$/, '').split('/').pop() ?? artifact.name;
261
+ const skillRoot = artifact.skillRoot ?? null;
262
+ const includeFiles = spec.includeFiles !== false;
263
+ const files = includeFiles
264
+ ? collectFiles(skillRoot, spec.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES, spec.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES)
265
+ : {};
266
+ const body = {
267
+ dimensionId: spec.id,
268
+ params: spec.params ?? {},
269
+ skill: {
270
+ name: skillName,
271
+ content,
272
+ skillRoot,
273
+ ref: artifact.ref ?? null,
274
+ files,
275
+ },
276
+ };
277
+ const controller = new AbortController();
278
+ const timer = setTimeout(() => controller.abort(), ctx.timeoutMs);
279
+ let parsed;
280
+ try {
281
+ let res;
282
+ try {
283
+ res = await fetchFn(spec.endpoint, {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/json', ...spec.headers },
286
+ body: JSON.stringify(body),
287
+ signal: controller.signal,
288
+ // redirect:'manual' —— 不跟随重定向。否则一个可信公网 endpoint 返回
289
+ // 302 Location: http://169.254.169.254/… 即可让 fetch 透明跳到私网,
290
+ // 绕过上面的 host 校验(请求前只校验一次原始 URL)。3xx 一律拒绝。
291
+ redirect: 'manual',
292
+ });
293
+ }
294
+ catch (err) {
295
+ const msg = err instanceof Error ? err.message : String(err);
296
+ return {
297
+ status: 'fail',
298
+ message: failMsg(ctx, spec, `请求失败:${msg}`, `request failed: ${msg}`),
299
+ hint: hintNet(ctx, spec.endpoint),
300
+ detail: { endpoint: spec.endpoint, error: msg },
301
+ };
302
+ }
303
+ if (res.status >= 300 && res.status < 400) {
304
+ const location = res.headers?.get?.('location') ?? null;
305
+ return {
306
+ status: 'fail',
307
+ message: failMsg(ctx, spec, `endpoint 返回重定向(HTTP ${res.status}),默认拒绝以防 SSRF(重定向目标可能指向私网)`, `endpoint returned a redirect (HTTP ${res.status}); refused by default to prevent SSRF (the redirect target may point to a private host)`),
308
+ hint: hintNet(ctx, spec.endpoint),
309
+ detail: { endpoint: spec.endpoint, httpStatus: res.status, location },
310
+ };
311
+ }
312
+ const declaredLen = Number(res.headers?.get?.('content-length') ?? '');
313
+ if (Number.isFinite(declaredLen) && declaredLen > MAX_RESPONSE_BYTES) {
314
+ return {
315
+ status: 'fail',
316
+ message: failMsg(ctx, spec, `响应体过大(Content-Length ${declaredLen} 字节,上限 ${MAX_RESPONSE_BYTES})`, `response body too large (Content-Length ${declaredLen} bytes, limit ${MAX_RESPONSE_BYTES})`),
317
+ hint: hintProto(ctx),
318
+ detail: { endpoint: spec.endpoint, contentLength: declaredLen },
319
+ };
320
+ }
321
+ if (!res.ok) {
322
+ return {
323
+ status: 'fail',
324
+ message: failMsg(ctx, spec, `接口返回 HTTP ${res.status}`, `endpoint returned HTTP ${res.status}`),
325
+ hint: hintNet(ctx, spec.endpoint),
326
+ detail: { endpoint: spec.endpoint, httpStatus: res.status },
327
+ };
328
+ }
329
+ try {
330
+ parsed = await res.json();
331
+ }
332
+ catch (err) {
333
+ const msg = err instanceof Error ? err.message : String(err);
334
+ return {
335
+ status: 'fail',
336
+ message: failMsg(ctx, spec, `响应不是合法 JSON:${msg}`, `response is not valid JSON: ${msg}`),
337
+ hint: hintProto(ctx),
338
+ detail: { endpoint: spec.endpoint, error: msg },
339
+ };
340
+ }
341
+ }
342
+ finally {
343
+ // timer 覆盖 fetch + body 读取(res.json()):两者都完成或任一出错后才清,
344
+ // 防止慢速/滴流响应体绕过 ctx.timeoutMs 无限挂起。
345
+ clearTimeout(timer);
346
+ }
347
+ const resp = parsed;
348
+ if (!isValidStatus(resp.status) || typeof resp.message !== 'string') {
349
+ return {
350
+ status: 'fail',
351
+ message: failMsg(ctx, spec, '响应缺少必填字段 status(pass/warn/fail) 或 message', 'response missing required field status (pass/warn/fail) or message'),
352
+ hint: hintProto(ctx),
353
+ // received 截断:接口可能返回超大 body,避免无界塞进 report JSON。
354
+ detail: { endpoint: spec.endpoint, received: JSON.stringify(parsed).slice(0, MAX_OUTCOME_CHARS) },
355
+ };
356
+ }
357
+ // 合法响应同样截断:message / hint / detail 都来自外部接口,不加界会被
358
+ // 无界写入 outcome 落盘(与上面 received 的处理风格对齐)。
359
+ return {
360
+ status: resp.status,
361
+ message: `${spec.displayName}: ${clampText(resp.message)}`,
362
+ hint: typeof resp.hint === 'string' ? clampText(resp.hint) : resp.hint,
363
+ detail: resp.detail == null ? { endpoint: spec.endpoint } : clampDetail(resp.detail),
364
+ };
365
+ },
366
+ };
367
+ }
368
+ function failMsg(ctx, spec, zh, en) {
369
+ const head = `${spec.displayName}`;
370
+ return ctx.lang === 'zh' ? `${head}: ${zh}` : `${head}: ${en}`;
371
+ }
372
+ function hintNet(ctx, endpoint) {
373
+ return ctx.lang === 'zh'
374
+ ? `确认接口 ${endpoint} 可达、鉴权 header 正确,或调大 --timeout`
375
+ : `Verify endpoint ${endpoint} is reachable, auth headers are correct, or raise --timeout`;
376
+ }
377
+ function hintProto(ctx) {
378
+ return ctx.lang === 'zh'
379
+ ? '接口需返回 JSON: { status: "pass"|"warn"|"fail", message: string, hint?, detail? }'
380
+ : 'Endpoint must return JSON: { status: "pass"|"warn"|"fail", message: string, hint?, detail? }';
381
+ }
@@ -1 +1,7 @@
1
+ /**
2
+ * 加载自定义维度 YAML 并注册。两类维度按字段自动分流:
3
+ * - 带 `endpoint` → 接口维度,注册成独立 DoctorRule(运行时 POST skill 快照)。
4
+ * - 带 `promptSection` → LLM 维度,注册进 health composer(原逻辑)。
5
+ * 返回成功注册的维度数量。
6
+ */
1
7
  export declare function loadAndRegisterCustomDimensions(filePath: string): number;
@@ -1,7 +1,25 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import yaml from 'js-yaml';
3
3
  import { registerHealthDimension } from './dimension-registry.js';
4
+ import { registerRule } from '../rules.js';
5
+ import { makeEndpointRule } from '../endpoint-rule.js';
4
6
  const VALID_SEVERITIES = new Set(['fatal', 'warn', 'info']);
7
+ /** 缺省 → warn;非法值直接 throw(与 id 缺失同等对待),避免用户写了
8
+ * `severity: critical` 想要 fatal 却被静默降成 warn、endpoint fail 不再 gate。 */
9
+ function resolveSeverity(dimId, raw) {
10
+ if (raw == null)
11
+ return 'warn'; // 缺省/空值 → 默认 warn
12
+ if (!VALID_SEVERITIES.has(raw)) {
13
+ throw new Error(`自定义维度 ${dimId} 的 severity 非法:${raw}(合法值:fatal / warn / info)`);
14
+ }
15
+ return raw;
16
+ }
17
+ /**
18
+ * 加载自定义维度 YAML 并注册。两类维度按字段自动分流:
19
+ * - 带 `endpoint` → 接口维度,注册成独立 DoctorRule(运行时 POST skill 快照)。
20
+ * - 带 `promptSection` → LLM 维度,注册进 health composer(原逻辑)。
21
+ * 返回成功注册的维度数量。
22
+ */
5
23
  export function loadAndRegisterCustomDimensions(filePath) {
6
24
  if (!existsSync(filePath)) {
7
25
  throw new Error(`自定义维度配置文件不存在: ${filePath}`);
@@ -12,10 +30,32 @@ export function loadAndRegisterCustomDimensions(filePath) {
12
30
  return 0;
13
31
  let count = 0;
14
32
  for (const dim of doc.dimensions) {
15
- if (!dim.id || !dim.displayName || !dim.promptSection) {
16
- throw new Error(`自定义维度缺少必填字段(id/displayName/promptSection): ${JSON.stringify(dim).slice(0, 100)}`);
33
+ if (!dim.id || !dim.displayName) {
34
+ throw new Error(`自定义维度缺少必填字段(id/displayName): ${JSON.stringify(dim).slice(0, 100)}`);
35
+ }
36
+ if (dim.endpoint && dim.promptSection) {
37
+ throw new Error(`自定义维度 ${dim.id} 不能同时配置 endpoint 与 promptSection(二选一)`);
38
+ }
39
+ const severity = resolveSeverity(dim.id, dim.severity);
40
+ if (dim.endpoint) {
41
+ registerRule(makeEndpointRule({
42
+ id: dim.id,
43
+ displayName: dim.displayName,
44
+ severity,
45
+ endpoint: dim.endpoint,
46
+ params: dim.params,
47
+ includeFiles: dim.includeFiles,
48
+ headers: dim.headers,
49
+ maxFileBytes: dim.maxFileBytes,
50
+ maxTotalBytes: dim.maxTotalBytes,
51
+ allowPrivateHost: dim.allowPrivateHost,
52
+ }));
53
+ count++;
54
+ continue;
55
+ }
56
+ if (!dim.promptSection) {
57
+ throw new Error(`自定义维度 ${dim.id} 必须配置 endpoint 或 promptSection 之一`);
17
58
  }
18
- const severity = VALID_SEVERITIES.has(dim.severity ?? '') ? dim.severity : 'warn';
19
59
  const spec = {
20
60
  id: dim.id,
21
61
  displayName: dim.displayName,
@@ -206,11 +206,17 @@ export async function runDoctor(opts) {
206
206
  const skillReports = [];
207
207
  const totals = { pass: 0, warn: 0, fail: 0 };
208
208
  const ruleStats = { pass: 0, warn: 0, fail: 0, skipped: 0, total: 0 };
209
+ const total = artifacts.length;
210
+ let index = 0;
209
211
  for (const artifact of artifacts) {
212
+ index += 1;
213
+ const skillName = basename(artifact.name).replace(/\.md$/, '');
214
+ opts.onProgress?.({ phase: 'skill_start', index, total, skillName });
215
+ const startedAt = Date.now();
210
216
  const results = await runRulesOnArtifact(artifact, effectiveRules, ctxBase);
211
217
  const status = classifySkillStatus(results);
212
218
  skillReports.push({
213
- skillName: basename(artifact.name).replace(/\.md$/, ''),
219
+ skillName,
214
220
  skillPath: inferSkillPath(artifact, opts.cwd),
215
221
  results,
216
222
  status,
@@ -220,6 +226,7 @@ export async function runDoctor(opts) {
220
226
  ruleStats[r.status] += 1;
221
227
  ruleStats.total += 1;
222
228
  }
229
+ opts.onProgress?.({ phase: 'skill_done', index, total, skillName, status, durationMs: Date.now() - startedAt });
223
230
  }
224
231
  // Single-enum verdict for CI / agent code. fatal-fail dominates;
225
232
  // warnings_only when no fatal-fail but at least one warn; otherwise passed.
@@ -229,7 +236,7 @@ export async function runDoctor(opts) {
229
236
  ? 'warnings_only'
230
237
  : 'passed';
231
238
  return {
232
- reportKind: 'doctor',
239
+ kind: 'doctor',
233
240
  schemaVersion: DOCTOR_REPORT_SCHEMA_VERSION,
234
241
  id: nextReportId(),
235
242
  timestamp: new Date().toISOString(),
@@ -1,5 +1,6 @@
1
1
  import type { Artifact, Report, Sample, Task, VariantResult, GitInfo, EvaluationJob, EvaluationRequest, EvaluationRun } from '../types/index.js';
2
2
  export declare const DEFAULT_OUTPUT_DIR: string;
3
+ export declare const EVALUATION_REPORT_SCHEMA_VERSION = 4;
3
4
  export declare function hashString(str: string): string;
4
5
  /**
5
6
  * Stable content hash of a sample. Hashes the prompt + assertions + dimensions/rubric
@@ -22,6 +22,7 @@ function findPackageJson(startDir) {
22
22
  }
23
23
  const PKG = JSON.parse(readFileSync(findPackageJson(__dirname), 'utf-8'));
24
24
  export const DEFAULT_OUTPUT_DIR = join(homedir(), '.oh-my-knowledge', 'reports');
25
+ export const EVALUATION_REPORT_SCHEMA_VERSION = 4;
25
26
  export function hashString(str) {
26
27
  return createHash('sha256').update(str).digest('hex').slice(0, 12);
27
28
  }
@@ -182,7 +183,7 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
182
183
  debiasModeList.push('length');
183
184
  const totalCostReported = Object.values(summary).every((variant) => variant.execCostReported !== false && variant.judgeCostReported !== false);
184
185
  return {
185
- reportKind: 'evaluation',
186
+ kind: 'evaluation',
186
187
  id: runId,
187
188
  meta: {
188
189
  variants,
@@ -197,9 +198,10 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
197
198
  cliVersion: getCliVersion(),
198
199
  nodeVersion: process.version,
199
200
  // schemaVersion 3 起,所有 dir-skill(本地 + git)都经隔离副本物化、整棵可分发树哈,与 install
200
- // 受管记录 contentHash 同空间(evidence 全绑)。2 是过渡纪元(本地 dir-skill 树哈、git dir-skill
201
- // 仅 SKILL.md 字节、不绑);git dir-skill 的 v2 与 v3 不可比。作判别位:消费方对缺位/旧报告不错配比对。
202
- schemaVersion: 3,
201
+ // 受管记录 contentHash 同空间(evidence 全绑)。4 延续 v3 的哈/绑定义,并作为当前 canonical
202
+ // 顶层判别字段纪元,方便外部消费方按版本识别 JSON 形状。2 是过渡纪元(本地 dir-skill 树哈、
203
+ // git dir-skill 仅 SKILL.md 字节、不绑);git dir-skill 的 v2 与 v3+ 不可比。
204
+ schemaVersion: EVALUATION_REPORT_SCHEMA_VERSION,
203
205
  artifactHashes,
204
206
  sampleHashes,
205
207
  ...(noJudge ? {} : { judgePromptHash: getJudgePromptHash(lengthDebiasOn) }),
@@ -1,5 +1,5 @@
1
1
  import { dirname } from 'node:path';
2
- import { DEFAULT_OUTPUT_DIR, generateRunId, getCliVersion, getGitInfo, persistReport } from '../eval-core/evaluation-reporting.js';
2
+ import { DEFAULT_OUTPUT_DIR, EVALUATION_REPORT_SCHEMA_VERSION, generateRunId, getCliVersion, getGitInfo, persistReport, } from '../eval-core/evaluation-reporting.js';
3
3
  import { buildEvaluationRequest, createEvaluationRun, createSucceededJob, finalizeEvaluationRun } from '../eval-core/evaluation-job.js';
4
4
  import { getExecutorRuntimeFingerprint } from '../executors/runtime-fingerprint.js';
5
5
  import { createFileJobStore, DEFAULT_JOBS_DIR } from '../server/job-store.js';
@@ -135,11 +135,12 @@ export function buildBatchEvaluationReport({ batchRunId, skillDir, skillEntries,
135
135
  ...(noJudge ? {} : { runtime: getExecutorRuntimeFingerprint(jc.executor, jc.model, { skillDir }) }),
136
136
  }));
137
137
  const report = {
138
- reportKind: 'batch-evaluation',
138
+ kind: 'batch-evaluation',
139
139
  id: batchRunId,
140
140
  mode: 'skill',
141
141
  meta: {
142
142
  mode: 'skill',
143
+ schemaVersion: EVALUATION_REPORT_SCHEMA_VERSION,
143
144
  model,
144
145
  executor: executorName,
145
146
  skillDir,
@@ -97,7 +97,7 @@ export async function runEvaluation({ samplesPath, skillDir, variantSpecs = [],
97
97
  const { createFileStore } = await import('../server/report-store.js');
98
98
  const store = createFileStore(resolve(outputDir || DEFAULT_OUTPUT_DIR));
99
99
  const existing = await store.get(resume);
100
- if (existing?.reportKind === 'evaluation') {
100
+ if (existing?.kind === 'evaluation') {
101
101
  existingResults = {};
102
102
  for (const entry of existing.results || []) {
103
103
  existingResults[entry.sample_id] = entry.variants;
@@ -121,7 +121,7 @@ export async function runEvaluation({ samplesPath, skillDir, variantSpecs = [],
121
121
  + ` 新 entries 不隔离 → 与现有 entries 不可比。建议恢复默认 strict-baseline。\n`);
122
122
  }
123
123
  }
124
- else if (existing?.reportKind === 'batch-evaluation') {
124
+ else if (existing?.kind === 'batch-evaluation') {
125
125
  process.stderr.write(`\n⚠️ report ${resume} is a BatchEvaluationReport; resume needs a child EvaluationReport, starting from scratch\n`);
126
126
  }
127
127
  else {
@@ -113,7 +113,11 @@ export function buildCodexArgs({ model, cwd, prompt }) {
113
113
  args.push('--model', model);
114
114
  if (cwd)
115
115
  args.push('-C', cwd);
116
- args.push(prompt);
116
+ // `--` end-of-options 分隔符:prompt 必须放在 `--` 之后。system prompt 被 prepend 时,
117
+ // skill 内容常以 YAML frontmatter `---` 开头,codex(clap)会把以 `-`/`--` 开头的位置参数
118
+ // 当未知 flag → exit 2(unexpected argument),72ms 秒退、不跑 agent。`--` 之后一律当
119
+ // positional,不再按 flag 解析(codex 自身的报错 tip 即建议此法)。bug:整个 skill eval 全失败。
120
+ args.push('--', prompt);
117
121
  return args;
118
122
  }
119
123
  // codex 看到 stdin 是 pipe 就当作 `<stdin>` 块读,会卡到 timeout。spawn 后立刻
@@ -52,7 +52,10 @@ const GIT_PROBE_STDIO = ['ignore', 'pipe', 'ignore'];
52
52
  // 由 resolveGitRepoContext 解出 repoRoot 后逐处显式传入。
53
53
  export function gitShowFile(ref, filePath, cwd = process.cwd()) {
54
54
  try {
55
- return execFileSync('git', ['cat-file', 'blob', `${ref}:${filePath}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO }).trim();
55
+ // `--` 隔断 tree-ish:ref 可能来自盘上受管记录的 locator(用户可手改 / 随仓库分发,被 omk list 等只读
56
+ // 命令喂进来),前缀 `-` 的 ref 不得被当成 git 选项解析(与 #219 fetch 路径同口径,见
57
+ // feedback_git_subprocess_dashdash)。加 `--` 后 dash-ref 退化为「非法 object name」fail-closed,普通 ref 输出不变。
58
+ return execFileSync('git', ['cat-file', 'blob', '--', `${ref}:${filePath}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO }).trim();
56
59
  }
57
60
  catch {
58
61
  return null;
@@ -64,7 +67,7 @@ export function gitShowFile(ref, filePath, cwd = process.cwd()) {
64
67
  */
65
68
  export function gitShowBytes(ref, filePath, cwd = process.cwd()) {
66
69
  try {
67
- return execFileSync('git', ['cat-file', 'blob', `${ref}:${filePath}`], { cwd, stdio: GIT_PROBE_STDIO }); // 无 encoding → Buffer
70
+ return execFileSync('git', ['cat-file', 'blob', '--', `${ref}:${filePath}`], { cwd, stdio: GIT_PROBE_STDIO }); // 无 encoding → Buffer;`--` 隔断同 gitShowFile
68
71
  }
69
72
  catch {
70
73
  return null;
@@ -80,7 +83,7 @@ export function gitShowBytes(ref, filePath, cwd = process.cwd()) {
80
83
  export function gitLsTreeBlobs(ref, treePath, cwd = process.cwd()) {
81
84
  let out;
82
85
  try {
83
- out = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', `${ref}:${treePath}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO });
86
+ out = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', '--', `${ref}:${treePath}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO });
84
87
  }
85
88
  catch {
86
89
  return [];
@@ -4,3 +4,5 @@
4
4
  */
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
+ export * from './list-view.js';
8
+ export * from './promote-gate.js';
@@ -4,3 +4,5 @@
4
4
  */
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
+ export * from './list-view.js';
8
+ export * from './promote-gate.js';