oh-my-knowledge 0.36.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 (67) 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 +86 -3
  5. package/dist/cli/commands/doctor.js +19 -4
  6. package/dist/cli/commands/evolve.d.ts +11 -0
  7. package/dist/cli/commands/evolve.js +105 -5
  8. package/dist/cli/commands/list.d.ts +5 -26
  9. package/dist/cli/commands/list.js +17 -142
  10. package/dist/cli/commands/promote.d.ts +24 -0
  11. package/dist/cli/commands/promote.js +158 -0
  12. package/dist/cli/commands/rollback.d.ts +20 -0
  13. package/dist/cli/commands/rollback.js +95 -0
  14. package/dist/cli/commands/sample.d.ts +1 -0
  15. package/dist/cli/commands/sample.js +10 -4
  16. package/dist/cli/lib/cell-format.d.ts +17 -0
  17. package/dist/cli/lib/cell-format.js +19 -0
  18. package/dist/cli/lib/cmd-flags.d.ts +1 -0
  19. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  20. package/dist/cli/lib/i18n-dict/common.js +8 -0
  21. package/dist/cli/lib/i18n-dict/list.d.ts +1 -1
  22. package/dist/cli/lib/i18n-dict/list.js +4 -0
  23. package/dist/cli/lib/i18n-dict/promote.d.ts +3 -0
  24. package/dist/cli/lib/i18n-dict/promote.js +54 -0
  25. package/dist/cli/lib/i18n-dict/rollback.d.ts +3 -0
  26. package/dist/cli/lib/i18n-dict/rollback.js +22 -0
  27. package/dist/cli/lib/i18n-dict.d.ts +3 -1
  28. package/dist/cli/lib/i18n-dict.js +4 -0
  29. package/dist/cli/lib/progress.d.ts +3 -0
  30. package/dist/cli/lib/progress.js +22 -0
  31. package/dist/cli/lib/source-probe.d.ts +15 -0
  32. package/dist/cli/lib/source-probe.js +129 -0
  33. package/dist/doctor/endpoint-rule.d.ts +60 -0
  34. package/dist/doctor/endpoint-rule.js +381 -0
  35. package/dist/doctor/health/load-custom-dimensions.d.ts +6 -0
  36. package/dist/doctor/health/load-custom-dimensions.js +43 -3
  37. package/dist/doctor/index.js +8 -1
  38. package/dist/executors/codex-cli.js +5 -1
  39. package/dist/managed/index.d.ts +1 -0
  40. package/dist/managed/index.js +1 -0
  41. package/dist/managed/list-view.d.ts +3 -0
  42. package/dist/managed/list-view.js +8 -4
  43. package/dist/managed/promote-gate.d.ts +40 -0
  44. package/dist/managed/promote-gate.js +37 -0
  45. package/dist/managed/store.d.ts +25 -2
  46. package/dist/managed/store.js +92 -3
  47. package/dist/renderer/doctor-detail-renderer.d.ts +9 -0
  48. package/dist/renderer/doctor-detail-renderer.js +114 -0
  49. package/dist/renderer/html-renderer.d.ts +3 -2
  50. package/dist/renderer/html-renderer.js +99 -99
  51. package/dist/renderer/icons.d.ts +29 -0
  52. package/dist/renderer/icons.js +66 -0
  53. package/dist/renderer/layout.js +81 -68
  54. package/dist/renderer/observation-inbox/styles.d.ts +1 -1
  55. package/dist/renderer/observation-inbox/styles.js +53 -53
  56. package/dist/renderer/report-shell.d.ts +77 -0
  57. package/dist/renderer/report-shell.js +223 -0
  58. package/dist/renderer/skill-detail-renderer.js +147 -352
  59. package/dist/renderer/skill-health-renderer.js +50 -73
  60. package/dist/renderer/skill-list-renderer.js +358 -306
  61. package/dist/renderer/summary.js +272 -162
  62. package/dist/renderer/test-view.d.ts +4 -3
  63. package/dist/renderer/test-view.js +386 -135
  64. package/dist/server/report-server.js +142 -28
  65. package/dist/types/doctor.d.ts +22 -0
  66. package/dist/types/managed.d.ts +17 -3
  67. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import type { ArtifactKind, DeriveManagedStateInput, DerivedManagedState, ManagedArtifactRecord, ManagedArtifactSource, ManagedDistributionTarget, ManagedEvidenceRef } from '../types/index.js';
1
+ import type { ArtifactKind, DeriveManagedStateInput, DerivedManagedState, ManagedArtifactRecord, ManagedArtifactSource, ManagedDecision, ManagedDistributionTarget, ManagedEvidenceRef } from '../types/index.js';
2
2
  /**
3
3
  * 受管记录的 per-record 文件存储。一条记录一个 `.omk/managed/<id>.json`,镜像 report-store 的
4
4
  * 成熟模式(原子 tmp+rename):每次 install 只碰自己那个文件,independent write、不丢别人、
@@ -44,6 +44,28 @@ export declare function upsertManagedRecord(dir: string, record: ManagedArtifact
44
44
  * null —— 这是"管理是 install 显式 opt-in"的体现:eval 绝不为未纳管的 skill 凭空建记录。
45
45
  */
46
46
  export declare function appendManagedEvidence(dir: string, recordId: string, evidence: ManagedEvidenceRef): ManagedArtifactRecord | null;
47
+ /**
48
+ * 当前内容(contentHash === record.contentHash)是否处于 promoted 态。决定是 append-only 事件流,
49
+ * promote 与 rollback 互为反操作,故不能看「是否存在过 promote」,而要看**当前内容最近一条** promote/rollback
50
+ * 决定是不是 promote —— rollback 之后再 promote 仍能恢复 promoted,反之亦然。换了内容(contentHash 变)后
51
+ * 旧内容的决定一概不算数。
52
+ *
53
+ * 「最近」按 decidedAt 真实时刻取,与 `latestCurrentEvidence` 同口径:omk 自写恒 UTC `Z`、字典序即时间序;
54
+ * 但记录可手改 / 随仓库分发,异偏移 ISO 串字典序会乱 → 两端可解析才用解析时刻,否则退字典序;并列取后出现的
55
+ * (数组追加序 = 事件序)。
56
+ */
57
+ export declare function isCurrentlyPromoted(record: ManagedArtifactRecord): boolean;
58
+ /**
59
+ * 追加一条人工管理决定(append-only,promote/reject/rollback 走此路)。与 evidence 同样**不能走 upsert**
60
+ * (`mergeManagedRecord` 刻意保留旧 decisions、丢弃 next.decisions),必须独立 load→push→原子重写。
61
+ *
62
+ * 幂等:对**当前内容**的 promote/rollback,看是否会改变当前 promoted 态——已 promoted 再 promote、未 promoted
63
+ * 再 rollback 都不追加、原样返回(由 CLI 提示「已 promoted」/「未 promoted」)。这让重复 `omk promote` /
64
+ * `omk rollback` 不堆冗余事件,但 promote↔rollback 的真实切换、以及换了内容(contentHash 变)后的新决定仍会
65
+ * 追加——正是要的版本史。其它 decisionKind(如 reject)仍按「同 kind + 同 contentHash 即 dedup」。记录不存在
66
+ * (未 install)返回 null,与 evidence 同口径(管理是 install 显式 opt-in,绝不为未纳管 skill 凭空建记录)。
67
+ */
68
+ export declare function appendManagedDecision(dir: string, recordId: string, decision: ManagedDecision): ManagedArtifactRecord | null;
47
69
  /**
48
70
  * 统一的记录构造点——所有消费方(CLI / 未来 server / SDK)经此组装,保证 schema 一致。
49
71
  * 只接收事实(身份、源、hash、分发落点);evidence/decisions 恒为空(eval/promote 的地盘)。
@@ -64,9 +86,10 @@ export declare function recordManagedArtifact(record: ManagedArtifactRecord, opt
64
86
  /**
65
87
  * 读时推导生命周期标签。
66
88
  * - 源缺失或 hash 漂(当前内容 ≠ 记录 contentHash)→ stale;
89
+ * - 否则当前内容已有 promote 决定 → promoted;
67
90
  * - 否则有**当前**证据 / samples → measurable;
68
91
  * - 否则 installed。
69
- * 「当前证据」= contentHash 与记录当前 contentHash 匹配的 evidence —— 旧内容的 evidence 不算数,
92
+ * 「当前证据 / 当前决定」= contentHash 与记录当前 contentHash 匹配的 evidence / decision —— 旧内容的不算数,
70
93
  * 这正是 #203「证据必须跟 artifact 一起走」的读时保证。`'discovered'` 留给无分发的记录,此函数不产生。
71
94
  */
72
95
  export declare function deriveManagedState(input: DeriveManagedStateInput): DerivedManagedState;
@@ -92,8 +92,29 @@ function isManagedArtifactRecord(value) {
92
92
  }
93
93
  return true;
94
94
  });
95
- const okDec = r.decisions.every((d) => d && typeof d === 'object'
96
- && isStringField(d.decisionKind));
95
+ const okDec = r.decisions.every((d) => {
96
+ if (!d || typeof d !== 'object')
97
+ return false;
98
+ const dec = d;
99
+ if (!isStringField(dec.decisionKind))
100
+ return false;
101
+ // promote/reject/rollback 的证据指针(promote 写)同样收窄:任意类型脏值不得穿过 validator 进
102
+ // deriveManagedState / promote 消费方。actor / decidedAt / reason 是 install 后才追加的可选展示字段,
103
+ // 旧记录(install 时 decisions 恒空)无,按 optional 读。
104
+ if (!isOptionalString(dec.actor) || !isOptionalString(dec.decidedAt) || !isOptionalString(dec.reason))
105
+ return false;
106
+ if (!isOptionalString(dec.contentHash) || !isOptionalString(dec.reportId))
107
+ return false;
108
+ if (dec.override !== undefined) {
109
+ const o = dec.override;
110
+ if (!o || typeof o !== 'object' || !isStringField(o.verdict))
111
+ return false;
112
+ if (o.overriddenBlocks !== undefined
113
+ && !(Array.isArray(o.overriddenBlocks) && o.overriddenBlocks.every((b) => typeof b === 'string')))
114
+ return false;
115
+ }
116
+ return true;
117
+ });
97
118
  return okDist && okEv && okDec;
98
119
  }
99
120
  export function loadManagedRecord(dir, id) {
@@ -218,6 +239,69 @@ export function appendManagedEvidence(dir, recordId, evidence) {
218
239
  }
219
240
  return merged;
220
241
  }
242
+ /**
243
+ * 当前内容(contentHash === record.contentHash)是否处于 promoted 态。决定是 append-only 事件流,
244
+ * promote 与 rollback 互为反操作,故不能看「是否存在过 promote」,而要看**当前内容最近一条** promote/rollback
245
+ * 决定是不是 promote —— rollback 之后再 promote 仍能恢复 promoted,反之亦然。换了内容(contentHash 变)后
246
+ * 旧内容的决定一概不算数。
247
+ *
248
+ * 「最近」按 decidedAt 真实时刻取,与 `latestCurrentEvidence` 同口径:omk 自写恒 UTC `Z`、字典序即时间序;
249
+ * 但记录可手改 / 随仓库分发,异偏移 ISO 串字典序会乱 → 两端可解析才用解析时刻,否则退字典序;并列取后出现的
250
+ * (数组追加序 = 事件序)。
251
+ */
252
+ export function isCurrentlyPromoted(record) {
253
+ const ms = (s) => { const n = Date.parse(s); return Number.isNaN(n) ? null : n; };
254
+ let latest;
255
+ for (const d of record.decisions) {
256
+ if (d.contentHash !== record.contentHash)
257
+ continue;
258
+ if (d.decisionKind !== 'promote' && d.decisionKind !== 'rollback')
259
+ continue;
260
+ if (latest === undefined) {
261
+ latest = d;
262
+ continue;
263
+ }
264
+ const tcur = ms(d.decidedAt);
265
+ const tlat = ms(latest.decidedAt);
266
+ const newer = tcur !== null && tlat !== null ? tcur >= tlat : d.decidedAt >= latest.decidedAt;
267
+ if (newer)
268
+ latest = d;
269
+ }
270
+ return latest?.decisionKind === 'promote';
271
+ }
272
+ /**
273
+ * 追加一条人工管理决定(append-only,promote/reject/rollback 走此路)。与 evidence 同样**不能走 upsert**
274
+ * (`mergeManagedRecord` 刻意保留旧 decisions、丢弃 next.decisions),必须独立 load→push→原子重写。
275
+ *
276
+ * 幂等:对**当前内容**的 promote/rollback,看是否会改变当前 promoted 态——已 promoted 再 promote、未 promoted
277
+ * 再 rollback 都不追加、原样返回(由 CLI 提示「已 promoted」/「未 promoted」)。这让重复 `omk promote` /
278
+ * `omk rollback` 不堆冗余事件,但 promote↔rollback 的真实切换、以及换了内容(contentHash 变)后的新决定仍会
279
+ * 追加——正是要的版本史。其它 decisionKind(如 reject)仍按「同 kind + 同 contentHash 即 dedup」。记录不存在
280
+ * (未 install)返回 null,与 evidence 同口径(管理是 install 显式 opt-in,绝不为未纳管 skill 凭空建记录)。
281
+ */
282
+ export function appendManagedDecision(dir, recordId, decision) {
283
+ const prev = loadManagedRecord(dir, recordId);
284
+ if (!prev)
285
+ return null;
286
+ const isPromotion = decision.decisionKind === 'promote' || decision.decisionKind === 'rollback';
287
+ const onCurrent = decision.contentHash !== undefined && decision.contentHash === prev.contentHash;
288
+ const dup = isPromotion && onCurrent
289
+ // promote/rollback 当前内容:不改变 promoted 态 → no-op(promote 当已 promoted、rollback 当未 promoted)。
290
+ ? (decision.decisionKind === 'promote') === isCurrentlyPromoted(prev)
291
+ : decision.contentHash !== undefined
292
+ && prev.decisions.some((d) => d.decisionKind === decision.decisionKind && d.contentHash === decision.contentHash);
293
+ const merged = dup
294
+ ? prev
295
+ : { ...prev, decisions: [...prev.decisions, decision] };
296
+ if (!dup) {
297
+ mkdirSync(dir, { recursive: true });
298
+ const path = recordPath(dir, recordId);
299
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
300
+ writeFileSync(tmp, JSON.stringify(merged, null, 2));
301
+ renameSync(tmp, path);
302
+ }
303
+ return merged;
304
+ }
221
305
  /**
222
306
  * 统一的记录构造点——所有消费方(CLI / 未来 server / SDK)经此组装,保证 schema 一致。
223
307
  * 只接收事实(身份、源、hash、分发落点);evidence/decisions 恒为空(eval/promote 的地盘)。
@@ -244,9 +328,10 @@ export function recordManagedArtifact(record, opts = {}) {
244
328
  /**
245
329
  * 读时推导生命周期标签。
246
330
  * - 源缺失或 hash 漂(当前内容 ≠ 记录 contentHash)→ stale;
331
+ * - 否则当前内容已有 promote 决定 → promoted;
247
332
  * - 否则有**当前**证据 / samples → measurable;
248
333
  * - 否则 installed。
249
- * 「当前证据」= contentHash 与记录当前 contentHash 匹配的 evidence —— 旧内容的 evidence 不算数,
334
+ * 「当前证据 / 当前决定」= contentHash 与记录当前 contentHash 匹配的 evidence / decision —— 旧内容的不算数,
250
335
  * 这正是 #203「证据必须跟 artifact 一起走」的读时保证。`'discovered'` 留给无分发的记录,此函数不产生。
251
336
  */
252
337
  export function deriveManagedState(input) {
@@ -255,6 +340,10 @@ export function deriveManagedState(input) {
255
340
  const drifted = currentContentHash === undefined || currentContentHash !== record.contentHash;
256
341
  if (drifted)
257
342
  return { label: 'stale', drifted: true, hasEvidence };
343
+ // 当前内容(contentHash 匹配)最近一条 promote/rollback 决定是 promote → 已人工接受当前版本,排在 measurable
344
+ // 之上。rollback 之后落回 measurable/installed;换了内容(contentHash 变)后旧决定不冒充当前。
345
+ if (isCurrentlyPromoted(record))
346
+ return { label: 'promoted', drifted: false, hasEvidence };
258
347
  if (hasEvidence || hasSamplesOrDoctorPass)
259
348
  return { label: 'measurable', drifted: false, hasEvidence };
260
349
  return { label: 'installed', drifted: false, hasEvidence };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Doctor 报告详情页 — 逐条规则的健康检查结果。
3
+ *
4
+ * 主次:Hero 综合健康分 → 统计条(通过/警告/失败)→ 规则列表(失败/警告高亮在前,
5
+ * 通过项折叠)。每条规则展开显示 finding 描述 + 修复建议。
6
+ */
7
+ import { type SkillReportContext } from './report-shell.js';
8
+ import type { Lang, DoctorReport } from '../types/index.js';
9
+ export declare function renderDoctorDetail(report: DoctorReport, skillName: string, langQ: string, lang?: Lang, skillContext?: SkillReportContext): string;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Doctor 报告详情页 — 逐条规则的健康检查结果。
3
+ *
4
+ * 主次:Hero 综合健康分 → 统计条(通过/警告/失败)→ 规则列表(失败/警告高亮在前,
5
+ * 通过项折叠)。每条规则展开显示 finding 描述 + 修复建议。
6
+ */
7
+ import { reportShell, healthColor } from './report-shell.js';
8
+ import { icon as svgIcon } from './icons.js';
9
+ import { e, DEFAULT_LANG } from './layout.js';
10
+ function getDetail(r) {
11
+ return (r.detail ?? {});
12
+ }
13
+ const SORT_ORDER = { fail: 0, warn: 1, pass: 2, skipped: 3 };
14
+ function fmtTs(ts) {
15
+ try {
16
+ const d = new Date(ts);
17
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
18
+ }
19
+ catch {
20
+ return ts;
21
+ }
22
+ }
23
+ function renderRuleCard(r, lang, forceOpen = false) {
24
+ const det = getDetail(r);
25
+ const name = det.displayName ?? r.ruleId;
26
+ const findings = det.findings ?? [];
27
+ const errors = findings.filter((f) => f.level === '错误');
28
+ const warns = findings.filter((f) => f.level === '警告');
29
+ const tips = findings.filter((f) => f.level === '建议');
30
+ const cardCls = r.status === 'fail' ? 'fail' : r.status === 'warn' ? 'warn' : 'pass';
31
+ const dotCls = r.status === 'fail' ? 'fail' : r.status === 'warn' ? 'warn' : r.status === 'skipped' ? 'skip' : 'pass';
32
+ const badges = [];
33
+ if (errors.length)
34
+ badges.push(`<span class="rs-badge rs-badge--fail">${errors.length} ${lang === 'zh' ? '错误' : 'err'}</span>`);
35
+ if (warns.length)
36
+ badges.push(`<span class="rs-badge rs-badge--warn">${warns.length} ${lang === 'zh' ? '警告' : 'warn'}</span>`);
37
+ if (!errors.length && !warns.length)
38
+ badges.push(`<span class="rs-badge rs-badge--pass">${lang === 'zh' ? '通过' : 'pass'}</span>`);
39
+ const head = `<span class="rs-card-dot rs-card-dot--${dotCls}"></span><span class="rs-card-name">${e(name)}</span><span class="rs-card-badges">${badges.join('')}</span>`;
40
+ const allFindings = [...errors, ...warns, ...tips];
41
+ if (allFindings.length === 0) {
42
+ return `<div class="rs-card rs-card--${cardCls}"><div class="rs-card-head" style="cursor:default">${head}</div></div>`;
43
+ }
44
+ const findingHtml = allFindings.map((f) => {
45
+ const isErr = f.level === '错误';
46
+ const mIcon = isErr
47
+ ? svgIcon('x', { size: 14, style: 'color:#dc2626;vertical-align:-2px;margin-right:5px' })
48
+ : svgIcon('warn', { size: 14, style: `color:${f.level === '警告' ? '#d97706' : '#637083'};vertical-align:-2px;margin-right:5px` });
49
+ return `<div class="rs-finding rs-finding--${isErr ? 'err' : 'warn'}">
50
+ <div class="rs-finding-desc">${mIcon}${e(f.description ?? '')}</div>
51
+ ${f.suggestion ? `<div class="rs-finding-sug">${svgIcon('bulb', { size: 13, style: 'vertical-align:-2px;margin-right:5px' })}${e(f.suggestion)}</div>` : ''}
52
+ </div>`;
53
+ }).join('');
54
+ return `<details class="rs-card rs-card--${cardCls}" ${errors.length || forceOpen ? 'open' : ''}>
55
+ <summary class="rs-card-head">${head}</summary>
56
+ <div class="rs-card-body">${findingHtml}</div>
57
+ </details>`;
58
+ }
59
+ function renderSkillBlock(sk, lang) {
60
+ const rules = sk.results.filter((r) => !r.ruleId.endsWith(':_summary'));
61
+ const pass = rules.filter((r) => r.status === 'pass').length;
62
+ const warn = rules.filter((r) => r.status === 'warn').length;
63
+ const fail = rules.filter((r) => r.status === 'fail').length;
64
+ const total = rules.length;
65
+ const sorted = [...rules].sort((a, b) => (SORT_ORDER[a.status] ?? 9) - (SORT_ORDER[b.status] ?? 9));
66
+ const issues = sorted.filter((r) => r.status === 'fail' || r.status === 'warn');
67
+ const oks = sorted.filter((r) => r.status === 'pass' || r.status === 'skipped');
68
+ const zh = lang === 'zh';
69
+ const stats = `<div class="rs-stats">
70
+ <div class="rs-stat rs-stat--pass"><span class="rs-stat-num">${pass}</span><span class="rs-stat-label">${zh ? '通过' : 'Pass'}</span></div>
71
+ <div class="rs-stat rs-stat--warn"><span class="rs-stat-num">${warn}</span><span class="rs-stat-label">${zh ? '警告' : 'Warn'}</span></div>
72
+ <div class="rs-stat rs-stat--fail"><span class="rs-stat-num">${fail}</span><span class="rs-stat-label">${zh ? '失败' : 'Fail'}</span></div>
73
+ <div class="rs-stat rs-stat--total"><span class="rs-stat-num">${total}</span><span class="rs-stat-label">${zh ? '规则总数' : 'Rules'}</span></div>
74
+ </div>`;
75
+ const issuesHtml = issues.length
76
+ ? `<div class="rs-panel"><div class="rs-panel-title">${zh ? '待处理项' : 'Issues'} <small>${issues.length}</small></div>${issues.map((r, i) => renderRuleCard(r, lang, i === 0)).join('')}</div>`
77
+ : `<div class="rs-panel"><div class="rs-empty">${zh ? '✓ 所有规则通过,无待处理项' : '✓ All rules passed'}</div></div>`;
78
+ const oksHtml = oks.length
79
+ ? `<div class="rs-panel"><details class="rs-fold"><summary>${zh ? `展开 ${oks.length} 条通过的规则` : `Show ${oks.length} passed rules`}</summary>${oks.map((r) => renderRuleCard(r, lang)).join('')}</details></div>`
80
+ : '';
81
+ return stats + issuesHtml + oksHtml;
82
+ }
83
+ export function renderDoctorDetail(report, skillName, langQ, lang = DEFAULT_LANG, skillContext) {
84
+ const zh = lang === 'zh';
85
+ // 优先展示点进来的 skill;找不到就展示全部
86
+ const targetSkill = report.skills.find((s) => s.skillName === skillName) ?? report.skills[0];
87
+ const rules = (targetSkill?.results ?? []).filter((r) => !r.ruleId.endsWith(':_summary'));
88
+ const pass = rules.filter((r) => r.status === 'pass').length;
89
+ const warn = rules.filter((r) => r.status === 'warn').length;
90
+ const total = rules.length;
91
+ const score = total > 0 ? Math.round(((pass + warn * 0.5) / total) * 100) : null;
92
+ // 与 eval「综合分 / 5」统一成「X分 / 满分」形式;圆心显原生分数,等级由颜色传达。
93
+ const scoreLabel = zh ? '健康分 / 100' : 'Health / 100';
94
+ const body = targetSkill
95
+ ? renderSkillBlock(targetSkill, lang)
96
+ : `<div class="rs-panel"><div class="rs-empty">${zh ? '报告无 skill 数据' : 'No skill data'}</div></div>`;
97
+ return reportShell({
98
+ dim: 'doctor',
99
+ icon: '🩺',
100
+ kindTitle: zh ? '健康体检报告' : 'Doctor Report',
101
+ skillName: targetSkill?.skillName ?? skillName,
102
+ metaItems: [`${svgIcon('clock', { size: 13 })} ${e(fmtTs(report.timestamp))}`, `${svgIcon('chip', { size: 13 })} ${e(report.model)}`, e(report.id)],
103
+ // Hero 展示「综合健康」总分(跨维度,与首页同口径),切 tab 不变;本次体检明细(规则/计数)在 body。
104
+ // 无 skillContext 时回退显本次体检健康分。
105
+ score: skillContext ? skillContext.overall.score : score,
106
+ scoreText: skillContext && skillContext.overall.score != null ? String(skillContext.overall.score) : undefined,
107
+ ringColor: skillContext ? healthColor(skillContext.overall.score) : undefined,
108
+ scoreLabel: skillContext ? (zh ? '综合健康' : 'Health') : scoreLabel,
109
+ backHref: `/${langQ}`,
110
+ backLabel: zh ? '返回列表' : 'Back to list',
111
+ body,
112
+ skillContext,
113
+ }, lang);
114
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * HTML report renderer — orchestrates sub-modules.
3
3
  */
4
+ import { type SkillReportContext } from './report-shell.js';
4
5
  import type { BatchEvaluationReport, EvaluationReport, Report, ReportDocument, Lang } from '../types/index.js';
5
6
  export declare function renderRunList(runs: ReportDocument[], lang?: Lang): string;
6
- export declare function renderRunDetail(report: EvaluationReport | null, lang?: Lang): string;
7
+ export declare function renderRunDetail(report: EvaluationReport | null, lang?: Lang, skillContext?: SkillReportContext): string;
7
8
  export declare function renderBatchEvaluationDetail(report: BatchEvaluationReport | null, lang?: Lang): string;
8
- export declare function renderReportDocumentDetail(report: ReportDocument | null, lang?: Lang): string;
9
+ export declare function renderReportDocumentDetail(report: ReportDocument | null, lang?: Lang, skillContext?: SkillReportContext): string;
9
10
  export declare function renderTrendsPage(variantName: string, runs: Report[], lang?: Lang): string;
@@ -3,9 +3,10 @@
3
3
  */
4
4
  import { e, fmtCost, fmtDuration, fmtKnownCost, COLORS, DEFAULT_LANG, t, layout } from './layout.js';
5
5
  import { renderAgentOverview, renderAnalysis, renderKnowledgeInteractionSection, renderMethodologyAudit, renderScoringModal, renderSummaryCards, renderVarianceComparisons, renderVerdictPill, levelLabel, levelTooltip, } from './summary.js';
6
- import { renderSampleTable } from './table.js';
7
6
  import { renderTestView, TEST_VIEW_CSS, TEST_VIEW_JS } from './test-view.js';
8
7
  import { renderTrendsBody } from './trends.js';
8
+ import { reportShell, healthColor } from './report-shell.js';
9
+ import { icon } from './icons.js';
9
10
  import { computeVerdict } from '../eval-core/verdict.js';
10
11
  // v0.21 B.4 — 列表页 status pill 用的 dot. PROGRESS/REGRESS 实心(强信号),
11
12
  // CAUTIOUS 三角(警示),NOISE 空心圆(有信号但无效果),UNDERPOWERED 部分填充
@@ -316,7 +317,7 @@ export function renderRunList(runs, lang = DEFAULT_LANG) {
316
317
  </main>
317
318
  `, lang);
318
319
  }
319
- export function renderRunDetail(report, lang = DEFAULT_LANG) {
320
+ export function renderRunDetail(report, lang = DEFAULT_LANG, skillContext) {
320
321
  const langQ = lang === DEFAULT_LANG ? '' : `?lang=${lang}`;
321
322
  if (!report) {
322
323
  return layout(t('title', lang), `
@@ -329,11 +330,24 @@ export function renderRunDetail(report, lang = DEFAULT_LANG) {
329
330
  const m = report.meta;
330
331
  const variants = m.variants || [];
331
332
  const summary = report.summary || {};
332
- const results = report.results || [];
333
333
  const cards = renderSummaryCards(variants, summary, lang, report.variance);
334
334
  const methodologyAudit = renderMethodologyAudit(report, variants, summary, lang);
335
- const verdictPill = renderVerdictPill(report, lang);
336
- const sampleTable = renderSampleTable(variants, results, lang);
335
+ // 单 variant(SOLO)不显示 verdict — 对照才有的概念,单组是噪音/误导(见融合设计)。
336
+ const verdictPill = variants.length > 1 ? renderVerdictPill(report, lang) : '';
337
+ // Hero:综合分 ring(取 treatment = 最后一个 variant 的综合分)+ skill 名 + 时间戳。
338
+ const treatmentName = variants[variants.length - 1];
339
+ const heroComposite = treatmentName ? (summary[treatmentName]?.avgCompositeScore ?? null) : null;
340
+ const heroScore100 = heroComposite != null ? Math.round((heroComposite / 5) * 100) : null;
341
+ const heroSkillName = report.meta.evolve?.skillName ?? treatmentName ?? report.id;
342
+ const heroTs = (() => {
343
+ try {
344
+ const d = new Date(report.meta.timestamp);
345
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
346
+ }
347
+ catch {
348
+ return '';
349
+ }
350
+ })();
337
351
  const totalExecCost = Object.values(summary).reduce((s, v) => s + (v.totalExecCostUSD || 0), 0);
338
352
  // 任一 variant exec cost 未报告 → 总 cost 不可靠,renderer 显示「—」+ tooltip。
339
353
  // 全部 reported(undefined / true)才正常显示 USD 数字。
@@ -420,14 +434,6 @@ export function renderRunDetail(report, lang = DEFAULT_LANG) {
420
434
  ` : '';
421
435
  // v0.30 重设计 — 评分/单测各自独立 title + meta strip(不共享头部)。
422
436
  // 顶部只保留极简身份带:返回链接 + run-id pill。完整 H1 + meta tags 移进各自 tab 面板。
423
- const idStamp = (() => {
424
- const idMatch = /^.+?-(\d{8})-(\d{4})$/.exec(report.id);
425
- if (idMatch) {
426
- const [, ymd, hm] = idMatch;
427
- return `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)} ${hm.slice(0, 2)}:${hm.slice(2, 4)}`;
428
- }
429
- return '';
430
- })();
431
437
  // ──────────── 评分 tab 的 meta tags(原版全部留这里) ────────────
432
438
  const scoreMetaTags = `<div class="meta-tags">
433
439
  <span class="meta-tag">${t('model', lang)}: ${e(m.model)}</span>
@@ -469,71 +475,61 @@ export function renderRunDetail(report, lang = DEFAULT_LANG) {
469
475
  ${Object.entries(m.blindMap || {}).map(([label, real]) => `<div style="font-size:13px;color:var(--text-secondary)"><strong>Variant ${e(label)}</strong> → ${e(real)}</div>`).join('')}
470
476
  </div>
471
477
  </div>` : '';
472
- // ──────────── 单测 tab 的 meta tags(只有 sample 维度数据,不含 cost / model) ────────────
473
- const sampleSnaps = report.sampleSnapshots ?? {};
474
- let testPassed = 0;
475
- let testFailed = 0;
476
- let testTripwire = 0;
477
- for (const r of (report.results || [])) {
478
- const v = m.variants?.[0] ? r.variants[m.variants[0]] : null;
479
- if (!v)
480
- continue;
481
- const passed = (v.assertions?.details ?? []).every((d) => d.passed);
482
- if (passed)
483
- testPassed += 1;
484
- else if (sampleSnaps[r.sample_id]?.tripwire || (v.diagnostic?.rootCause ?? []).includes('tripwire_intentional'))
485
- testTripwire += 1;
486
- else
487
- testFailed += 1;
488
- }
489
- const testMetaTags = `<div class="meta-tags">
490
- <span class="meta-tag">${m.sampleCount} ${lang === 'zh' ? '用例' : 'samples'}</span>
491
- <span class="meta-tag" style="color:var(--green)">✓ ${testPassed} ${lang === 'zh' ? '通过' : 'passed'}</span>
492
- ${testFailed > 0 ? `<span class="meta-tag" style="color:var(--red)">✗ ${testFailed} ${lang === 'zh' ? '失败' : 'failed'}</span>` : ''}
493
- ${testTripwire > 0 ? `<span class="meta-tag" style="color:#7a6b89">◆ ${testTripwire} ${lang === 'zh' ? '诱错触发' : 'tripwire'}</span>` : ''}
494
- <span class="meta-tag">${(m.variants || []).join(' · ')}</span>
495
- </div>`;
496
- return layout(`${report.id}`, `
497
- <main>
498
- <nav class="nav"><a href="/${langQ}" data-i18n="backToList">${t('backToList', lang)}</a></nav>
499
- <div class="report-id-line">
500
- <code class="report-id-pill">${e(report.id)}</code>
501
- ${idStamp ? `<span class="report-id-stamp">${e(idStamp)}</span>` : ''}
502
- </div>
503
-
504
- <div class="omk-view-tabs" role="tablist">
505
- <button type="button" class="omk-view-tab omk-view-tab--active" data-view="score" onclick="omkSwitchView('score')">${lang === 'zh' ? '📊 评测视角' : '📊 Score view'}</button>
506
- <button type="button" class="omk-view-tab" data-view="test" onclick="omkSwitchView('test')">${lang === 'zh' ? '✅ 功能视角' : '✅ Functional view'}</button>
507
- </div>
508
-
509
- <div class="omk-view-panel" data-view="score">
510
- <p class="subtitle">${lang === 'zh' ? '主观质量打分(judge)+ 客观断言聚合,产出综合评分与 verdict' : 'Subjective judge + objective assertions aggregated into composite + verdict'}</p>
511
- ${verdictPill}
512
- ${scoreMetaTags}
478
+ // ──────────── 融合单页:Hero → 结论(verdict+六维) → 折叠次要区 → 统一逐用例 ────────────
479
+ const agentOverview = renderAgentOverview(variants, summary, lang);
480
+ const varianceSection = renderVarianceComparisons(report.variance, lang, Boolean(report.meta.layeredStats), summary);
481
+ const analysisSection = renderAnalysis(report, lang);
482
+ const knowledgeSection = renderKnowledgeInteractionSection(report.analysis?.coverage, report.analysis?.gapReports, lang);
483
+ // 折叠区 A:实验如何跑的(配置 / 运行参数 / Agent 概览 / 审计指纹)— 复现/审计才看。
484
+ const setupInner = [
485
+ scoreMetaTags,
486
+ (variantConfigSection || agentOverview) ? `<div class="ctx-strip">${variantConfigSection}${agentOverview}</div>` : '',
487
+ auditFingerprints,
488
+ ].filter(Boolean).join('');
489
+ const setupFold = setupInner
490
+ ? `<details class="ev-fold"><summary>${lang === 'zh' ? '实验配置与运行参数' : 'Experiment setup & run params'}</summary><div class="ev-fold-body">${setupInner}</div></details>`
491
+ : '';
492
+ // 折叠区 B:深入分析(方法学审计 / 方差 / 自动分析 / 知识交互)— 想验证测量学时展开。
493
+ const analysisInner = [methodologyAudit, varianceSection, analysisSection, knowledgeSection].filter(Boolean).join('');
494
+ const analysisFold = analysisInner
495
+ ? `<details class="ev-fold"><summary>${lang === 'zh' ? '方法学审计 · 方差 · 自动分析 · 知识交互' : 'Methodology audit · variance · analysis · knowledge'}</summary><div class="ev-fold-body">${analysisInner}</div></details>`
496
+ : '';
497
+ // 「结论」panel:多 variant = verdict 一句话领头 + 六维表;单 variant = 直接六维分布。
498
+ const conclusionPanel = `<section class="ev-conclusion">${verdictPill}${cards}</section>`;
499
+ const samplesSubtitle = lang === 'zh'
500
+ ? '每条用例既是评分卡也是功能测试:综合分 + 三层分布、断言、诊断与执行轨迹合一'
501
+ : 'Each sample is both a score card and a functional test: composite + layered scores, assertions, diagnostics, and trace combined';
502
+ const body = `
503
+ ${conclusionPanel}
513
504
  ${blindReveal}
514
-
515
- <section>${cards}</section>
516
- <div class="ctx-strip">${variantConfigSection}${renderAgentOverview(variants, summary, lang)}</div>
517
- ${auditFingerprints}
518
- ${methodologyAudit}
519
-
520
- ${renderVarianceComparisons(report.variance, lang, Boolean(report.meta.layeredStats), summary)}
521
-
522
- ${renderAnalysis(report, lang)}
523
-
524
- ${renderKnowledgeInteractionSection(report.analysis?.coverage, report.analysis?.gapReports, lang)}
525
-
526
- <section>${sampleTable}</section>
527
- </div>
528
-
529
- <div class="omk-view-panel" data-view="test" hidden>
530
- <p class="subtitle">${lang === 'zh' ? '每条用例当一条功能测试看:期望(rubric/assertions/mocks)、实际执行(trace)、失败时给诊断建议' : 'Each sample as a functional test: expected(rubric/assertions/mocks), actual(trace), diagnostic suggestions on failure'}</p>
531
- ${testMetaTags}
532
- ${renderTestView(report, lang)}
533
- </div>
534
-
535
- <style>${TEST_VIEW_CSS}
536
- /* Context strip — 实验配置 + Agent 概览并排,作为辅助元数据,视觉权重低于综合分 hero / 六维 / 用例详情 */
505
+ ${setupFold}
506
+ ${analysisFold}
507
+ <section class="ev-samples">
508
+ <h2 class="ev-samples-h">${lang === 'zh' ? '逐用例' : 'Per-sample'}</h2>
509
+ <p class="subtitle ev-samples-sub">${samplesSubtitle}</p>
510
+ ${renderTestView(report, lang)}
511
+ </section>${TEST_VIEW_JS ? `<script>${TEST_VIEW_JS}</script>` : ''}`;
512
+ const evalCss = `${TEST_VIEW_CSS}
513
+ /* 结论 panel — verdict(多 variant)+ 六维,白底卡片成「答案」模块,与 hero / fold 同一视觉语言 */
514
+ .ev-conclusion{background:#fff;border:1px solid #e4e8f1;border-radius:12px;box-shadow:0 8px 24px rgba(31,41,55,4%);padding:18px 24px 20px;margin:0 0 16px}
515
+ .ev-conclusion>:first-child{margin-top:0}
516
+ .ev-conclusion>h2:first-child{margin-top:2px}
517
+ /* 次要折叠区 — 实验配置 / 方法学 / 方差 / 分析,默认收起,审计/复现时展开 */
518
+ .ev-fold{margin:10px 0;background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius)}
519
+ .ev-fold>summary{cursor:pointer;list-style:none;font-size:13px;font-weight:500;color:var(--text-secondary);padding:12px 16px}
520
+ .ev-fold>summary::-webkit-details-marker{display:none}
521
+ .ev-fold>summary::before{content:'▸ ';color:var(--text-muted)}
522
+ .ev-fold[open]>summary::before{content:'▾ '}
523
+ .ev-fold[open]>summary{border-bottom:1px solid var(--border);color:var(--text-primary)}
524
+ .ev-fold>summary:hover{color:var(--text-primary)}
525
+ .ev-fold-body{padding:8px 16px 16px}
526
+ .ev-fold-body>section:first-child,.ev-fold-body>.audit-fingerprints:first-child{margin-top:0}
527
+ .ev-fold-body .meta-tags{margin-top:8px}
528
+ /* 逐用例 section 标题 */
529
+ .ev-samples{margin-top:28px}
530
+ .ev-samples-h{font-size:1.4rem;margin:0 0 4px;color:var(--text-primary)}
531
+ .ev-samples-sub{font-size:13.5px;color:var(--text-secondary);margin:0 0 8px;line-height:1.6}
532
+ /* Context strip — 实验配置 + Agent 概览并排,辅助元数据 */
537
533
  .ctx-strip { display:grid;gap:14px;grid-template-columns:1fr 1fr;margin:14px 0 24px }
538
534
  @media (max-width:760px) { .ctx-strip { grid-template-columns:1fr } }
539
535
  .ctx-block { background:var(--bg-surface);border-radius:var(--radius);box-shadow:var(--shadow-sm);padding:12px 16px }
@@ -543,26 +539,30 @@ export function renderRunDetail(report, lang = DEFAULT_LANG) {
543
539
  .ctx-row { padding:8px 12px;background:var(--bg-soft);border-radius:4px;display:flex;align-items:center;gap:10px;font-size:13px;line-height:1.5;flex-wrap:wrap }
544
540
  .ctx-row-name { font-weight:600;color:var(--text-primary);font-family:"SF Mono",Menlo,monospace;font-size:12.5px;letter-spacing:-0.01em }
545
541
  .ctx-row-bits { color:var(--text-secondary);flex:1;min-width:0;font-size:12.5px }
546
- .ctx-sep { color:var(--text-muted);margin:0 2px;opacity:0.6 }
547
- /* 报告身份带 — 极简一行,run-id pill + 时间戳 + verdict pill。完整 H1 + meta 移进各 tab 自己的 panel。 */
548
- .report-id-line { display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:8px 0 18px }
549
- .report-id-pill { font-size:14px;font-family:"SF Mono",Menlo,monospace;color:var(--text-primary);background:var(--bg-surface);padding:5px 12px;border-radius:14px;border:1px solid var(--border);letter-spacing:-0.01em;font-weight:500 }
550
- .report-id-stamp { font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums }
551
- /* 顶部 tab 切换器 — 显式 reset border-radius / appearance / outline,
552
- 防止全局 button 规则给底部加奇怪圆角和深色背景。 */
553
- .omk-view-tabs { display:flex;gap:0;margin:16px 0 24px;border-bottom:1px solid var(--border) }
554
- .omk-view-tab { padding:12px 20px;background:transparent;border:none;border-radius:0;cursor:pointer;color:var(--text-secondary);border-bottom:2px solid transparent;font-size:14px;font-weight:500;outline:none;appearance:none;-webkit-appearance:none;margin-bottom:-1px;font-family:inherit }
555
- .omk-view-tab--active { color:var(--text-primary);border-bottom-color:var(--accent);font-weight:600 }
556
- .omk-view-tab:hover:not(.omk-view-tab--active) { color:var(--text-primary) }
557
- .omk-view-tab:focus-visible { outline:2px solid var(--accent);outline-offset:-4px;border-radius:4px }
558
- /* tab 面板内的 H1 — 跟列表 H1 同级别但加 emoji 区分视角 */
559
- .omk-view-panel h1 { font-size:1.5rem;margin:0 0 6px }
560
- .omk-view-panel .subtitle { font-size:14px;color:var(--text-secondary);margin:0 0 20px;line-height:1.6 }
561
- </style>
562
- <script>${TEST_VIEW_JS}</script>
563
-
564
- </main>
565
- `, lang);
542
+ .ctx-sep { color:var(--text-muted);margin:0 2px;opacity:0.6 }`;
543
+ return reportShell({
544
+ dim: 'eval',
545
+ icon: '🧪',
546
+ kindTitle: lang === 'zh' ? '用例评测报告' : 'Eval Report',
547
+ // 头部用规范 skill 名(skill-index entry),与 doctor/observe 一致;无 context 时回退到 report 推导名。
548
+ skillName: skillContext?.skillName ?? heroSkillName,
549
+ metaItems: [`${icon('clock', { size: 13 })} ${e(heroTs)}`, `${icon('chip', { size: 13 })} ${e(m.model)}`, e(report.id)],
550
+ // Hero 展示「综合健康」总分(跨 doctor/eval/observe,与首页同口径),切 tab 不变;
551
+ // 本次 eval 的综合分(4.09/5)在下方结论卡。无 skillContext(batch/孤儿报告)时回退显 eval 综合分。
552
+ score: skillContext ? skillContext.overall.score : heroScore100,
553
+ scoreText: skillContext
554
+ ? (skillContext.overall.score != null ? String(skillContext.overall.score) : undefined)
555
+ : (heroComposite != null ? heroComposite.toFixed(2) : undefined),
556
+ ringColor: skillContext
557
+ ? healthColor(skillContext.overall.score)
558
+ : (heroComposite != null ? (heroComposite >= 4 ? '#1f9d63' : heroComposite >= 3 ? '#d97706' : '#dc2626') : undefined),
559
+ scoreLabel: skillContext ? (lang === 'zh' ? '综合健康' : 'Health') : (lang === 'zh' ? '综合分 / 5' : 'Composite / 5'),
560
+ backHref: `/${langQ}`,
561
+ backLabel: lang === 'zh' ? '返回列表' : 'Back to list',
562
+ body,
563
+ skillContext,
564
+ extraCss: evalCss,
565
+ }, lang);
566
566
  }
567
567
  export function renderBatchEvaluationDetail(report, lang = DEFAULT_LANG) {
568
568
  const langQ = lang === DEFAULT_LANG ? '' : `?lang=${lang}`;
@@ -649,12 +649,12 @@ export function renderBatchEvaluationDetail(report, lang = DEFAULT_LANG) {
649
649
  </main>
650
650
  `, lang);
651
651
  }
652
- export function renderReportDocumentDetail(report, lang = DEFAULT_LANG) {
652
+ export function renderReportDocumentDetail(report, lang = DEFAULT_LANG, skillContext) {
653
653
  if (!report)
654
654
  return renderRunDetail(null, lang);
655
655
  return report.kind === 'batch-evaluation'
656
656
  ? renderBatchEvaluationDetail(report, lang)
657
- : renderRunDetail(report, lang);
657
+ : renderRunDetail(report, lang, skillContext);
658
658
  }
659
659
  export function renderTrendsPage(variantName, runs, lang = DEFAULT_LANG) {
660
660
  const body = renderTrendsBody(variantName, runs, lang);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 共享单色线性 SVG 图标集(替代各处 emoji)。
3
+ *
4
+ * 设计:统一 24×24 viewBox、`stroke="currentColor"`、单一描边权重、圆角端点。
5
+ * 颜色由调用处的 CSS `color` 控制(图标自身不带色),尺寸/粗细可覆盖。
6
+ * 之所以自带一套而不用图标库:报告是离线自包含 HTML,内联 SVG 无需外链/字体。
7
+ */
8
+ /** name → viewBox 内的 path/shape 串。 */
9
+ declare const PATHS: Record<string, string>;
10
+ export type IconName = keyof typeof PATHS;
11
+ export interface IconOpts {
12
+ /** 像素尺寸(宽高相同),默认 16。 */
13
+ size?: number;
14
+ /** 额外 class。 */
15
+ cls?: string;
16
+ /** 描边权重,默认 1.7。 */
17
+ sw?: number;
18
+ /** 内联 style。 */
19
+ style?: string;
20
+ }
21
+ /** 返回一个自包含 `<svg>` 串。未知 name 返回空串(不抛错,调用处可安全拼接)。 */
22
+ export declare function icon(name: string, opts?: IconOpts): string;
23
+ /** 维度 → 报告类型图标名。 */
24
+ export declare function dimIconName(dim: 'doctor' | 'eval' | 'observe'): IconName;
25
+ /** 品牌 logo 的完整 `<svg>`(给定像素尺寸)。 */
26
+ export declare function brandLogo(size?: number): string;
27
+ /** 品牌 logo 的原始 `<svg>`(无尺寸,供 favicon data URI 编码用)。 */
28
+ export declare const BRAND_LOGO_RAW = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 120 120\" fill=\"none\"><defs><linearGradient id=\"omkAccent\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"0%\"><stop offset=\"0%\" stop-color=\"#60a5fa\"/><stop offset=\"100%\" stop-color=\"#a78bfa\"/></linearGradient></defs><circle cx=\"60\" cy=\"60\" r=\"56\" fill=\"#0f172a\" stroke=\"#1e293b\" stroke-width=\"1.5\"/><path d=\"M24 60 A12 12 0 1 1 36 72\" stroke=\"url(#omkAccent)\" stroke-width=\"3.5\" stroke-linecap=\"round\" fill=\"none\"/><path d=\"M56 72 V57 Q56 48 61.5 48 Q67 48 67 57 V72 M67 57 Q67 48 72.5 48 Q78 48 78 57 V72\" stroke=\"#e2e8f0\" stroke-width=\"3.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\"/><path d=\"M86 48 V72 M86 60 L96 48 M86 60 L96 72\" stroke=\"#e2e8f0\" stroke-width=\"3.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\"/></svg>";
29
+ export {};