oh-my-knowledge 0.37.0 → 0.38.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 (92) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/dist/assets/agent-skills/omk/SKILL.md +1 -1
  4. package/dist/assets/agent-skills/omk/references/commands.md +21 -12
  5. package/dist/authoring/evolver.d.ts +4 -1
  6. package/dist/authoring/evolver.js +13 -8
  7. package/dist/cli/commands/doctor.d.ts +1 -0
  8. package/dist/cli/commands/doctor.js +26 -6
  9. package/dist/cli/commands/eval/gold/compare.js +7 -4
  10. package/dist/cli/commands/eval/index.d.ts +1 -0
  11. package/dist/cli/commands/eval/index.js +14 -3
  12. package/dist/cli/commands/evolve.d.ts +1 -0
  13. package/dist/cli/commands/evolve.js +45 -0
  14. package/dist/cli/commands/init.js +18 -6
  15. package/dist/cli/commands/list.d.ts +0 -2
  16. package/dist/cli/commands/list.js +2 -7
  17. package/dist/cli/commands/observe/inbox.d.ts +1 -0
  18. package/dist/cli/commands/observe/inbox.js +14 -4
  19. package/dist/cli/commands/observe/index.d.ts +11 -0
  20. package/dist/cli/commands/observe/index.js +35 -12
  21. package/dist/cli/commands/observe/ingest.d.ts +1 -0
  22. package/dist/cli/commands/observe/ingest.js +13 -4
  23. package/dist/cli/commands/observe/show.d.ts +1 -0
  24. package/dist/cli/commands/observe/show.js +12 -2
  25. package/dist/cli/commands/promote.js +1 -2
  26. package/dist/cli/commands/sample.js +15 -9
  27. package/dist/cli/commands/studio.d.ts +2 -0
  28. package/dist/cli/commands/studio.js +62 -12
  29. package/dist/cli/lib/cmd-flags.d.ts +8 -0
  30. package/dist/cli/lib/i18n-dict/common.js +1 -1
  31. package/dist/cli/lib/i18n-dict/evolve.d.ts +1 -1
  32. package/dist/cli/lib/i18n-dict/evolve.js +8 -0
  33. package/dist/cli/lib/i18n-dict/help.js +27 -45
  34. package/dist/cli/lib/i18n-dict/init.js +2 -2
  35. package/dist/cli/lib/parse-run-config.d.ts +0 -1
  36. package/dist/cli/lib/parse-run-config.js +6 -4
  37. package/dist/cli/lib/record-evolve-outcome.d.ts +31 -0
  38. package/dist/cli/lib/record-evolve-outcome.js +110 -0
  39. package/dist/cli/lib/resolve-skill-input.d.ts +6 -0
  40. package/dist/cli/lib/resolve-skill-input.js +4 -2
  41. package/dist/diagnosis/types.js +1 -1
  42. package/dist/doctor/index.js +4 -1
  43. package/dist/eval-core/artifact-index.d.ts +106 -0
  44. package/dist/eval-core/artifact-index.js +277 -0
  45. package/dist/eval-core/default-dirs.d.ts +34 -0
  46. package/dist/eval-core/default-dirs.js +36 -0
  47. package/dist/eval-core/evaluation-execution.js +3 -3
  48. package/dist/eval-core/evaluation-reporting.d.ts +7 -0
  49. package/dist/eval-core/evaluation-reporting.js +20 -5
  50. package/dist/eval-core/execution-strategy.js +3 -3
  51. package/dist/eval-core/measurement-dirs.d.ts +18 -0
  52. package/dist/eval-core/measurement-dirs.js +76 -0
  53. package/dist/eval-workflows/batch-evaluation-workflow.js +2 -1
  54. package/dist/eval-workflows/evaluation-pipeline/run-state.js +2 -1
  55. package/dist/eval-workflows/run-evaluation.js +5 -2
  56. package/dist/executors/codex-cli.js +2 -2
  57. package/dist/executors/script.js +43 -4
  58. package/dist/inputs/materialize-copy.d.ts +1 -1
  59. package/dist/inputs/materialize-copy.js +3 -3
  60. package/dist/inputs/skill-loader.d.ts +1 -1
  61. package/dist/inputs/skill-loader.js +1 -1
  62. package/dist/managed/index.d.ts +2 -0
  63. package/dist/managed/index.js +2 -0
  64. package/dist/managed/list-query.d.ts +7 -0
  65. package/dist/managed/list-query.js +11 -0
  66. package/dist/managed/source-probe.d.ts +15 -0
  67. package/dist/managed/source-probe.js +129 -0
  68. package/dist/managed/store.d.ts +10 -0
  69. package/dist/managed/store.js +25 -2
  70. package/dist/observability/inbox.js +6 -3
  71. package/dist/observability/skill-health-analyzer.d.ts +3 -0
  72. package/dist/observability/skill-health-analyzer.js +2 -0
  73. package/dist/renderer/html-renderer.js +4 -2
  74. package/dist/renderer/managed-history-renderer.d.ts +5 -0
  75. package/dist/renderer/managed-history-renderer.js +255 -0
  76. package/dist/renderer/observation-inbox-renderer.js +19 -19
  77. package/dist/renderer/skill-detail-renderer.js +17 -8
  78. package/dist/renderer/skill-list-renderer.js +1 -0
  79. package/dist/server/indexed-report-store.d.ts +8 -0
  80. package/dist/server/indexed-report-store.js +130 -0
  81. package/dist/server/job-store.d.ts +0 -1
  82. package/dist/server/job-store.js +0 -2
  83. package/dist/server/report-server.d.ts +17 -3
  84. package/dist/server/report-server.js +231 -85
  85. package/dist/server/report-store.d.ts +14 -8
  86. package/dist/server/report-store.js +46 -0
  87. package/dist/server/skill-index.d.ts +7 -1
  88. package/dist/server/skill-index.js +48 -12
  89. package/dist/types/executor.d.ts +4 -2
  90. package/dist/types/report.d.ts +15 -0
  91. package/dist/types/skill-index.d.ts +3 -0
  92. package/package.json +1 -1
@@ -5,12 +5,26 @@ interface ReportServerOptions {
5
5
  * 暴露到容器外 / 局域网用 '0.0.0.0'。也可走 OMK_REPORT_HOST 环境变量。 */
6
6
  host?: string;
7
7
  reportsDir?: string;
8
- analysesDir?: string;
9
- doctorsDir?: string;
8
+ /** observe-health 报告目录(项目 .omk/observe-health 或全局),或一个按请求动态解析它的函数。
9
+ * 传函数 → 每次请求重解析(长会话里项目首次 omk observe 后从 global 实时切回 project);
10
+ * 传字符串 → 固定到该目录(测试 / 显式覆盖);默认动态解析 project→global 权威目录。 */
11
+ analysesDir?: string | (() => string);
12
+ /** 体检报告目录(项目 .omk/doctors 或全局),或一个按请求动态解析它的函数。三模式同 analysesDir。 */
13
+ doctorsDir?: string | (() => string);
10
14
  observationsDir?: string;
11
15
  jobsDir?: string;
16
+ /** 受管目录(.omk/managed 或全局),或一个按请求动态解析它的函数。只读消费,供 /managed 决策史页。
17
+ * 传函数 → 每次请求重解析(长会话里跟 omk list 同口径:项目首次 install 后从 global 实时切回 project);
18
+ * 传字符串 → 固定到该目录(测试 / 显式覆盖);默认动态解析 project→global 权威目录。 */
19
+ managedDir?: string | (() => string);
12
20
  store?: ReportStore;
13
21
  jobStore?: JobStore;
22
+ /** 是否把别项目的 observe-health 索引卡片合进列表 / 详情 / skill 首页(机器级总览)。
23
+ * 默认 false(固定目录 / 测试 hermetic);studio 仅在「无 --analyses-dir 且无 --global」的默认机器级模式传 true。 */
24
+ includeObserveCards?: boolean;
25
+ /** 是否把别项目的 doctor 索引卡片合进 skill 首页 / 详情兜底(机器级总览)。
26
+ * 默认 false;studio 仅在「无 --doctors-dir 且无 --global」的默认机器级模式传 true。 */
27
+ includeDoctorCards?: boolean;
14
28
  }
15
29
  interface ReportServer {
16
30
  start(): Promise<string>;
@@ -18,5 +32,5 @@ interface ReportServer {
18
32
  getUrl(): string | null;
19
33
  }
20
34
  export declare function formatListenError(p: number, err: unknown): Error | null;
21
- export declare function createReportServer({ port, host: hostOption, reportsDir, analysesDir, doctorsDir, observationsDir, jobsDir, store, jobStore }?: ReportServerOptions): ReportServer;
35
+ export declare function createReportServer({ port, host: hostOption, reportsDir, analysesDir, doctorsDir, observationsDir, jobsDir, managedDir, store, jobStore, includeObserveCards, includeDoctorCards }?: ReportServerOptions): ReportServer;
22
36
  export {};
@@ -2,7 +2,6 @@ import { createServer } from 'node:http';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { join } from 'node:path';
5
- import { homedir } from 'node:os';
6
5
  import { renderReportDocumentDetail, renderTrendsPage, renderRunList } from '../renderer/html-renderer.js';
7
6
  import { renderSkillList } from '../renderer/skill-list-renderer.js';
8
7
  import { renderSkillHealthReport } from '../renderer/skill-health-renderer.js';
@@ -10,9 +9,15 @@ import { renderDoctorDetail } from '../renderer/doctor-detail-renderer.js';
10
9
  import { assessHealth } from '../renderer/skill-detail-renderer.js';
11
10
  import { renderObservationInboxPage } from '../renderer/observation-inbox-renderer.js';
12
11
  import { DEFAULT_LANG, t, layout } from '../renderer/layout.js';
12
+ import { loadAllManagedRecords, resolveManagedDir, managedDir as projectManagedDir, listManagedRows } from '../managed/index.js';
13
+ import { renderManagedList, renderManagedHistory } from '../renderer/managed-history-renderer.js';
14
+ import { DEFAULT_JOBS_DIR } from '../eval-core/default-dirs.js';
15
+ import { resolveObserveHealthDir, projectObserveHealthDir, resolveDoctorsDir, projectDoctorsDir, projectReportsDir, globalReportsDir } from '../eval-core/measurement-dirs.js';
16
+ import { listObserveCards, listDoctorCards, listLiveObserveCards } from '../eval-core/artifact-index.js';
13
17
  import { buildSkillIndex } from './skill-index.js';
14
- import { createFileJobStore, DEFAULT_JOBS_DIR } from './job-store.js';
18
+ import { createFileJobStore } from './job-store.js';
15
19
  import { createFileStore, queryJob, queryJobList, queryRun, queryRunList, queryTrend } from './report-store.js';
20
+ import { createIndexedReportStore } from './indexed-report-store.js';
16
21
  import { confidenceOf } from '../observability/skill-health-analyzer.js';
17
22
  import { DEFAULT_OBSERVATIONS_DIR, findObservationInboxItem, formatObservationShow, queryObservationInbox } from '../observability/inbox.js';
18
23
  import { buildObservationInboxViewModel } from '../observability/inbox-view-model.js';
@@ -21,9 +26,6 @@ import { deleteObservationReviewState, loadObservationReviewState, updateObserva
21
26
  import { updateSkillDerivedStandardStatus } from '../observability/soft-standards/index.js';
22
27
  const DEFAULT_PORT = 7799;
23
28
  const PORT_HINT = `OMK_REPORT_PORT=${DEFAULT_PORT} omk eval ...`;
24
- const DEFAULT_REPORTS_DIR = join(homedir(), '.oh-my-knowledge', 'reports');
25
- const DEFAULT_ANALYSES_DIR = join(homedir(), '.oh-my-knowledge', 'analyses');
26
- const DEFAULT_DOCTORS_DIR = join(homedir(), '.oh-my-knowledge', 'doctors');
27
29
  let cachedChartJsBytes;
28
30
  function loadChartJsBundle() {
29
31
  if (cachedChartJsBytes !== undefined)
@@ -42,75 +44,124 @@ function loadChartJsBundle() {
42
44
  }
43
45
  return cachedChartJsBytes;
44
46
  }
45
- function listAnalyses(dir) {
46
- if (!existsSync(dir))
47
- return [];
47
+ function listAnalyses(dir, includeCards = false) {
48
48
  const items = [];
49
- for (const file of readdirSync(dir)) {
50
- if (!file.endsWith('.json'))
51
- continue;
52
- try {
53
- const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
54
- if (!data.meta || !data.overall)
49
+ // live 扫描 dir 存在才做;dir 不存在(默认机器级模式下当前项目还没 .omk/observe-health、全局也空)时 live 为空,
50
+ // 但**不能早退** —— 后面仍要按 includeCards 合并别项目卡片,否则 observe 列表会与合卡片的 /api/skills 口径分裂。
51
+ if (existsSync(dir)) {
52
+ for (const file of readdirSync(dir)) {
53
+ if (!file.endsWith('.json'))
54
+ continue;
55
+ try {
56
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
57
+ if (!data.meta || !data.overall)
58
+ continue;
59
+ items.push({
60
+ id: file.replace(/\.json$/, ''),
61
+ generatedAt: data.meta.generatedAt,
62
+ sessionCount: data.meta.sessionCount,
63
+ segmentCount: data.meta.segmentCount,
64
+ skillCount: Object.keys(data.bySkill || {}).length,
65
+ healthBand: data.overall.healthBand,
66
+ // 旧 JSON 缺 confidence 时按 segmentCount 兜底,跟 Studio / CLI 口径一致。
67
+ confidence: data.overall.confidence ?? confidenceOf(data.meta.segmentCount),
68
+ });
69
+ }
70
+ catch { /* skip corrupt */ }
71
+ }
72
+ }
73
+ // 别项目的 observe 卡片(当前 dir live 扫不到的项目)→ list item,dedup by id(live 盖卡片)。
74
+ // 仅机器级模式合并;固定 --analyses-dir / --global 时 includeCards=false,只看该目录(逃生舱语义)。
75
+ if (includeCards) {
76
+ const seen = new Set(items.map((i) => i.id));
77
+ for (const card of listLiveObserveCards()) {
78
+ if (seen.has(card.id))
55
79
  continue;
56
80
  items.push({
57
- id: file.replace(/\.json$/, ''),
58
- generatedAt: data.meta.generatedAt,
59
- sessionCount: data.meta.sessionCount,
60
- segmentCount: data.meta.segmentCount,
61
- skillCount: Object.keys(data.bySkill || {}).length,
62
- healthBand: data.overall.healthBand,
63
- // 旧 JSON 缺 confidence 时按 segmentCount 兜底,跟 Studio / CLI 口径一致。
64
- confidence: data.overall.confidence ?? confidenceOf(data.meta.segmentCount),
81
+ id: card.id, generatedAt: card.meta.generatedAt, sessionCount: card.meta.sessionCount,
82
+ segmentCount: card.meta.segmentCount, skillCount: Object.keys(card.bySkill).length,
83
+ healthBand: card.overall.healthBand, confidence: card.overall.confidence ?? confidenceOf(card.meta.segmentCount),
65
84
  });
66
85
  }
67
- catch { /* skip corrupt */ }
68
86
  }
69
87
  // 最新在前
70
88
  items.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
71
89
  return items;
72
90
  }
73
- function loadAnalysis(dir, id) {
91
+ function loadAnalysis(dir, id, includeCards = false) {
74
92
  const path = join(dir, `${id}.json`);
75
- if (!existsSync(path))
76
- return null;
77
- try {
78
- return JSON.parse(readFileSync(path, 'utf-8'));
93
+ if (existsSync(path)) {
94
+ try {
95
+ return JSON.parse(readFileSync(path, 'utf-8'));
96
+ }
97
+ catch { /* fall through to card */ }
79
98
  }
80
- catch {
99
+ // 别项目:按 observe 卡片 path 读真身(含 signals 等完整详情)。悬空(项目被移走)→ null,详情页 404。
100
+ // 仅机器级模式兜底;固定目录 / --global 不回源别项目卡片(逃生舱语义)。
101
+ if (!includeCards)
81
102
  return null;
103
+ const card = listObserveCards().find((c) => c.id === id);
104
+ if (card && existsSync(card.path)) {
105
+ try {
106
+ return JSON.parse(readFileSync(card.path, 'utf-8'));
107
+ }
108
+ catch { /* corrupt 真身 */ }
82
109
  }
110
+ return null;
83
111
  }
84
112
  /** 扫 doctorsDir 找 id 匹配的 doctor 报告(文件名不一定等于 report id)。
85
113
  * 批量 doctor 会按 skill 拆成多份共享同一 id 的 per-skill 文件,传 skillName 时
86
114
  * 优先返回含该 skill 的那份;都不含时回退首个 id 命中(单 skill / 无参行为不变)。 */
87
- function loadDoctorReport(dir, id, skillName) {
88
- if (!existsSync(dir))
89
- return null;
115
+ function loadDoctorReport(dir, id, skillName, includeCards = false) {
90
116
  let fallback = null;
91
- for (const file of readdirSync(dir)) {
92
- if (!file.endsWith('.json'))
117
+ if (existsSync(dir)) {
118
+ for (const file of readdirSync(dir)) {
119
+ if (!file.endsWith('.json'))
120
+ continue;
121
+ try {
122
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
123
+ if (data?.kind !== 'doctor' || data.id !== id)
124
+ continue;
125
+ if (!skillName || data.skills?.some((s) => s.skillName === skillName))
126
+ return data;
127
+ fallback ??= data;
128
+ }
129
+ catch { /* skip */ }
130
+ }
131
+ }
132
+ if (fallback)
133
+ return fallback;
134
+ // 仅机器级模式兜底;固定 --doctors-dir / --global 不回源别项目卡片(逃生舱语义)。
135
+ if (!includeCards)
136
+ return null;
137
+ // 别项目:按 doctor 卡片(reportId 匹配 + 可选 skillName)的 path 读真身。detail 路由传的 id 是 reportId(非卡片 stem)。
138
+ for (const card of listDoctorCards()) {
139
+ if (card.reportId !== id)
140
+ continue;
141
+ if (skillName && card.skillName !== skillName)
142
+ continue;
143
+ if (!existsSync(card.path))
93
144
  continue;
94
145
  try {
95
- const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
96
- if (data?.kind !== 'doctor' || data.id !== id)
97
- continue;
98
- if (!skillName || data.skills?.some((s) => s.skillName === skillName))
99
- return data;
100
- fallback ??= data;
146
+ const data = JSON.parse(readFileSync(card.path, 'utf-8'));
147
+ if (data?.kind === 'doctor' && data.id === id) {
148
+ if (!skillName || data.skills?.some((s) => s.skillName === skillName))
149
+ return data;
150
+ fallback ??= data;
151
+ }
101
152
  }
102
- catch { /* skip */ }
153
+ catch { /* corrupt 真身 */ }
103
154
  }
104
155
  return fallback;
105
156
  }
106
157
  /**
107
158
  * 扫 analyses/ 所有 JSON,按 skillName 过滤,按时间排序成 trend points。
108
159
  */
109
- function querySkillTrend(dir, skillName) {
110
- const items = listAnalyses(dir);
160
+ function querySkillTrend(dir, skillName, includeCards = false) {
161
+ const items = listAnalyses(dir, includeCards);
111
162
  const points = [];
112
163
  for (const it of items) {
113
- const report = loadAnalysis(dir, it.id);
164
+ const report = loadAnalysis(dir, it.id, includeCards);
114
165
  if (!report)
115
166
  continue;
116
167
  const h = report.bySkill[skillName];
@@ -144,9 +195,9 @@ function querySkillTrend(dir, skillName) {
144
195
  * 比较两份 skill health report. `from` 通常是较早的,`to` 是较晚的;
145
196
  * 对于每个 skill, 显示前后值和 delta. 缺失一侧时 presence 标记。
146
197
  */
147
- function querySkillDiff(dir, fromId, toId) {
148
- const from = loadAnalysis(dir, fromId);
149
- const to = loadAnalysis(dir, toId);
198
+ function querySkillDiff(dir, fromId, toId, includeCards = false) {
199
+ const from = loadAnalysis(dir, fromId, includeCards);
200
+ const to = loadAnalysis(dir, toId, includeCards);
150
201
  if (!from || !to)
151
202
  return null;
152
203
  const allSkills = new Set([...Object.keys(from.bySkill), ...Object.keys(to.bySkill)]);
@@ -324,9 +375,9 @@ function renderSkillDiffPage(diff, lang = DEFAULT_LANG) {
324
375
  const body = `
325
376
  <main style="max-width:1000px;margin:0 auto;padding:24px">
326
377
  <nav style="margin-bottom:8px">
327
- <a href="/analyses${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none;margin-right:12px">${t('backToAnalyses', lang)}</a>
328
- <a href="/analyses/${encodeURIComponent(fromId)}${langQ}" data-i18n="diffNavFrom" style="color:var(--accent);text-decoration:none;margin-right:12px">${t('diffNavFrom', lang)}</a>
329
- <a href="/analyses/${encodeURIComponent(toId)}${langQ}" data-i18n="diffNavTo" style="color:var(--accent);text-decoration:none">${t('diffNavTo', lang)}</a>
378
+ <a href="/observe-health${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none;margin-right:12px">${t('backToAnalyses', lang)}</a>
379
+ <a href="/observe-health/${encodeURIComponent(fromId)}${langQ}" data-i18n="diffNavFrom" style="color:var(--accent);text-decoration:none;margin-right:12px">${t('diffNavFrom', lang)}</a>
380
+ <a href="/observe-health/${encodeURIComponent(toId)}${langQ}" data-i18n="diffNavTo" style="color:var(--accent);text-decoration:none">${t('diffNavTo', lang)}</a>
330
381
  </nav>
331
382
  <h1 data-i18n="skillDiffHeading" style="font-size:20px;margin:8px 0">${t('skillDiffHeading', lang)}</h1>
332
383
  <div style="color:var(--text-muted);font-size:13px;margin-bottom:20px">
@@ -352,7 +403,7 @@ function renderSkillTrendPage(trend, lang = DEFAULT_LANG) {
352
403
  if (points.length === 0) {
353
404
  const emptyBody = `
354
405
  <main style="max-width:900px;margin:0 auto;padding:24px">
355
- <nav style="margin-bottom:12px"><a href="/analyses${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none">${t('backToAnalyses', lang)}</a></nav>
406
+ <nav style="margin-bottom:12px"><a href="/observe-health${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none">${t('backToAnalyses', lang)}</a></nav>
356
407
  <h1 style="font-size:20px;margin:8px 0 4px"><span data-i18n="skillTrendHeading">${t('skillTrendHeading', lang)}</span> · ${skillName}</h1>
357
408
  <p style="color:var(--text-muted)" data-i18n="noTrendData">${t('noTrendData', lang)}</p>
358
409
  </main>`;
@@ -389,7 +440,7 @@ function renderSkillTrendPage(trend, lang = DEFAULT_LANG) {
389
440
  <span style="color:#4ade80">● <span data-i18n="trendLegendCoverage">${t('trendLegendCoverage', lang)}</span></span>
390
441
  </div>`;
391
442
  const rows = points.map((p) => `<tr>
392
- <td style="padding:6px 10px;font-family:ui-monospace,monospace;font-size:12px"><a href="/analyses/${encodeURIComponent(p.analysisId)}${langQ}" style="color:var(--accent);text-decoration:none">${p.generatedAt.slice(0, 19).replace('T', ' ')}</a></td>
443
+ <td style="padding:6px 10px;font-family:ui-monospace,monospace;font-size:12px"><a href="/observe-health/${encodeURIComponent(p.analysisId)}${langQ}" style="color:var(--accent);text-decoration:none">${p.generatedAt.slice(0, 19).replace('T', ' ')}</a></td>
393
444
  <td style="padding:6px 10px;text-align:right">${p.segmentCount}</td>
394
445
  <td style="padding:6px 10px;text-align:right;color:#f87171">${Math.round(p.gapRate * 100)}%</td>
395
446
  <td style="padding:6px 10px;text-align:right;color:#fbbf24">${Math.round(p.weightedGapRate * 100)}%</td>
@@ -401,7 +452,7 @@ function renderSkillTrendPage(trend, lang = DEFAULT_LANG) {
401
452
  const subtitle = `${points.length} <span data-i18n="trendNPoints">${t('trendNPoints', lang)}</span> · <span data-i18n="trendEarliest">${t('trendEarliest', lang)}</span> ${points[0].generatedAt.slice(0, 10)} · <span data-i18n="trendLatest">${t('trendLatest', lang)}</span> ${points[points.length - 1].generatedAt.slice(0, 10)}`;
402
453
  const body = `
403
454
  <main style="max-width:900px;margin:0 auto;padding:24px">
404
- <nav style="margin-bottom:8px"><a href="/analyses${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none">${t('backToAnalyses', lang)}</a></nav>
455
+ <nav style="margin-bottom:8px"><a href="/observe-health${langQ}" data-i18n="backToAnalyses" style="color:var(--accent);text-decoration:none">${t('backToAnalyses', lang)}</a></nav>
405
456
  <h1 style="font-size:20px;margin:8px 0 4px"><span data-i18n="skillTrendHeading">${t('skillTrendHeading', lang)}</span> · ${skillName}</h1>
406
457
  <div style="color:var(--text-muted);font-size:13px;margin-bottom:16px">${subtitle}</div>
407
458
  ${svg}
@@ -443,7 +494,7 @@ function renderAnalysisList(items, lang = DEFAULT_LANG) {
443
494
  <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${badgeColor}"></span>
444
495
  <label style="font-size:11px;color:var(--text-muted);display:flex;align-items:center;gap:3px"><input type="radio" name="from" value="${enc}" onchange="updateCompare()"> <span data-i18n="analysesFromLabel">${t('analysesFromLabel', lang)}</span></label>
445
496
  <label style="font-size:11px;color:var(--text-muted);display:flex;align-items:center;gap:3px"><input type="radio" name="to" value="${enc}" onchange="updateCompare()"> <span data-i18n="analysesToLabel">${t('analysesToLabel', lang)}</span></label>
446
- <a href="/analyses/${enc}${langQ}" style="color:var(--accent);text-decoration:none;flex:1;font-family:ui-monospace,monospace">${it.id}</a>
497
+ <a href="/observe-health/${enc}${langQ}" style="color:var(--accent);text-decoration:none;flex:1;font-family:ui-monospace,monospace">${it.id}</a>
447
498
  <span style="color:var(--text-muted);font-size:12px">${it.sessionCount} <span data-i18n="analysesSessions">${t('analysesSessions', lang)}</span> · ${it.segmentCount} <span data-i18n="analysesSegs">${t('analysesSegs', lang)}</span> · ${it.skillCount} <span data-i18n="analysesSkills">${t('analysesSkills', lang)}</span>${underpowered ? ` · <span data-i18n="analysesLowN" style="color:var(--text-faint)">${t('analysesLowN', lang)}</span>` : ''}</span>
448
499
  </li>`;
449
500
  }).join('');
@@ -509,18 +560,51 @@ export function formatListenError(p, err) {
509
560
  // EADDRINUSE or unknown — let caller try the omk-takeover flow.
510
561
  return null;
511
562
  }
512
- export function createReportServer({ port, host: hostOption, reportsDir = DEFAULT_REPORTS_DIR, analysesDir = DEFAULT_ANALYSES_DIR, doctorsDir = DEFAULT_DOCTORS_DIR, observationsDir = DEFAULT_OBSERVATIONS_DIR, jobsDir = DEFAULT_JOBS_DIR, store, jobStore } = {}) {
563
+ export function createReportServer({ port, host: hostOption, reportsDir, analysesDir, doctorsDir, observationsDir = DEFAULT_OBSERVATIONS_DIR, jobsDir = DEFAULT_JOBS_DIR, managedDir, store, jobStore, includeObserveCards = false, includeDoctorCards = false } = {}) {
513
564
  let server = null;
514
565
  let serverUrl = null;
515
- // Use provided store or default to file store
516
- const reportStore = store || createFileStore(reportsDir);
566
+ // reports store:显式注入 store > 显式 reportsDir(eval serve 的本次 outputDir / 测试 / 覆盖,固定单目录)
567
+ // > 缺省 indexed(机器级总览:当前项目 + 全局 live ∪ 别项目的索引卡片,按 id dedup)。indexed 在 store 层做
568
+ // merge,底层 createFileStore 的 mtime 指纹自动反映内容变化,一次构建即可。
569
+ const reportStore = store
570
+ ?? (reportsDir !== undefined
571
+ ? createFileStore(reportsDir)
572
+ : createIndexedReportStore({ projectDir: projectReportsDir(), globalDir: globalReportsDir() }));
517
573
  const resolvedJobStore = jobStore || createFileJobStore(jobsDir);
574
+ // 受管根目录按**请求**解析,不在启动时冻结 —— 否则长会话里会跟 omk list 分叉:Studio 启动时项目 .omk/managed
575
+ // 还空、回退到 global,随后用户在项目里首次 omk install,omk list 下次会切到 project,而冻结了 root 的 Studio
576
+ // 仍盯着旧 global,页面与 CLI 不一致。cwd 在进程内不变,变的是目录里有没有记录,故每次请求重判。
577
+ // - 传函数 → 直接当解析器,每次请求调用(测试可注入受控解析器复现 project↔global 切换);
578
+ // - 传字符串 → 固定该目录(显式覆盖 / 测试);
579
+ // - 缺省 → 动态解析 project→global 权威目录,与 omk list 同口径。
580
+ const resolveManagedRoot = typeof managedDir === 'function'
581
+ ? managedDir
582
+ : managedDir !== undefined
583
+ ? () => managedDir
584
+ : () => resolveManagedDir(projectManagedDir());
585
+ // observe-health 目录同 managed 按**请求**解析(项目 .omk/observe-health 优先、空则全局兜底) ——
586
+ // 长会话里项目首次 omk observe 后,studio 下次请求即从 global 切回 project,不在启动时冻结。
587
+ // 三模式同 managedDir:函数透传 / 字符串固定 / 缺省动态解析。
588
+ const resolveAnalysesDir = typeof analysesDir === 'function'
589
+ ? analysesDir
590
+ : analysesDir !== undefined
591
+ ? () => analysesDir
592
+ : () => resolveObserveHealthDir(projectObserveHealthDir());
593
+ // doctors 目录同 analyses 按请求解析(项目优先→全局兜底),三模式同 managedDir / analysesDir。
594
+ const resolveDoctorsRoot = typeof doctorsDir === 'function'
595
+ ? doctorsDir
596
+ : doctorsDir !== undefined
597
+ ? () => doctorsDir
598
+ : () => resolveDoctorsDir(projectDoctorsDir());
518
599
  async function handleRequest(req, res) {
519
600
  try {
520
601
  const parsed = new URL(req.url || '/', 'http://127.0.0.1');
521
602
  const path = parsed.pathname;
522
603
  const langParam = parsed.searchParams.get('lang');
523
604
  const lang = langParam === 'en' ? 'en' : langParam === 'zh' ? 'zh' : DEFAULT_LANG;
605
+ // observe-health / doctors 目录每请求解析一次(项目优先→全局兜底),下面各 handler 用这两个 local 字符串。
606
+ const analysesDir = resolveAnalysesDir();
607
+ const doctorsDir = resolveDoctorsRoot();
524
608
  if (path === '/health') {
525
609
  res.writeHead(200, { 'Content-Type': 'application/json' });
526
610
  res.end(JSON.stringify({ ok: true, service: 'omk' }));
@@ -553,24 +637,87 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
553
637
  res.end(JSON.stringify(runs));
554
638
  return;
555
639
  }
556
- if (path === '/api/analyses') {
640
+ // 旧 observe 路由 → observe-* 词根 canonical 的兜底(querystring 透传),防外链 / 书签 / 已打开页面的旧 fetch 失效。
641
+ // 页面用 302(临时);API 用 307 —— review-state 有 POST/DELETE,302 会被客户端降级成 GET,307 保留 method+body。
642
+ // 复合名 /analyses-diff、/api/analyses-diff、/skill-trend 维持原名,不在此重定向。
643
+ const legacyObserveRedirect = (() => {
644
+ if (path === '/analyses')
645
+ return { to: '/observe-health', status: 302 };
646
+ if (path === '/observations' || path === '/observations/inbox')
647
+ return { to: '/observe-inbox', status: 302 };
648
+ const detail = path.match(/^\/analyses\/(.+)$/);
649
+ if (detail)
650
+ return { to: `/observe-health/${detail[1]}`, status: 302 };
651
+ if (path === '/api/analyses')
652
+ return { to: '/api/observe-health', status: 307 };
653
+ const apiDetail = path.match(/^\/api\/analyses\/(.+)$/);
654
+ if (apiDetail)
655
+ return { to: `/api/observe-health/${apiDetail[1]}`, status: 307 };
656
+ if (path === '/api/observations/inbox')
657
+ return { to: '/api/observe-inbox', status: 307 };
658
+ if (path === '/api/observations/show')
659
+ return { to: '/api/observe-inbox/show', status: 307 };
660
+ if (path === '/api/observations/diagnostics')
661
+ return { to: '/api/observe-inbox/diagnostics', status: 307 };
662
+ if (path === '/api/observations/review-state')
663
+ return { to: '/api/observe-inbox/review-state', status: 307 };
664
+ return null;
665
+ })();
666
+ if (legacyObserveRedirect) {
667
+ res.writeHead(legacyObserveRedirect.status, { Location: legacyObserveRedirect.to + parsed.search });
668
+ res.end();
669
+ return;
670
+ }
671
+ if (path === '/api/observe-health') {
557
672
  res.writeHead(200, { 'Content-Type': 'application/json' });
558
- res.end(JSON.stringify(listAnalyses(analysesDir)));
673
+ res.end(JSON.stringify(listAnalyses(analysesDir, includeObserveCards)));
674
+ return;
675
+ }
676
+ if (path === '/observe-health') {
677
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
678
+ res.end(renderAnalysisList(listAnalyses(analysesDir, includeObserveCards), lang));
679
+ return;
680
+ }
681
+ // 受管 skill 决策史(#203 管理支柱可视化出口)。只读受管记录,口径同 omk list。
682
+ if (path === '/api/managed') {
683
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
684
+ res.end(JSON.stringify({ schemaVersion: 1, rows: listManagedRows(resolveManagedRoot()) }));
685
+ return;
686
+ }
687
+ if (path === '/managed') {
688
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
689
+ res.end(renderManagedList(listManagedRows(resolveManagedRoot()), lang));
559
690
  return;
560
691
  }
561
- if (path === '/analyses') {
692
+ const managedDetailMatch = path.match(/^\/managed\/(.+)$/);
693
+ if (managedDetailMatch) {
694
+ let id;
695
+ try {
696
+ id = decodeURIComponent(managedDetailMatch[1]);
697
+ }
698
+ catch {
699
+ id = '';
700
+ }
701
+ // 按稳定 id(= hash(kind, name)) 精确查 —— 同名不同 kind(skill/review vs prompt/review)各有独立 id,
702
+ // 不会串到同一页;且只在已加载、已校验的记录里查,不拼文件路径、无路径穿越。
703
+ const record = id ? loadAllManagedRecords(resolveManagedRoot()).find((r) => r.id === id) : undefined;
704
+ if (!record) {
705
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
706
+ res.end(lang === 'en' ? 'managed record not found' : '受管记录不存在');
707
+ return;
708
+ }
562
709
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
563
- res.end(renderAnalysisList(listAnalyses(analysesDir), lang));
710
+ res.end(renderManagedHistory(record, lang));
564
711
  return;
565
712
  }
566
- if (path === '/observations' || path === '/observations/inbox') {
713
+ if (path === '/observe-inbox') {
567
714
  const skill = parsed.searchParams.get('skill') || undefined;
568
715
  const html = renderObservationInboxPage(buildObservationInboxViewModel(observationsDir, { skill }), lang);
569
716
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
570
717
  res.end(html);
571
718
  return;
572
719
  }
573
- if (path === '/api/observations/inbox') {
720
+ if (path === '/api/observe-inbox') {
574
721
  const severity = parsed.searchParams.get('severity');
575
722
  const skill = parsed.searchParams.get('skill');
576
723
  const limitRaw = parsed.searchParams.get('limit');
@@ -588,9 +735,9 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
588
735
  res.end(JSON.stringify(items));
589
736
  return;
590
737
  }
591
- if (path === '/api/observations/diagnostics') {
738
+ if (path === '/api/observe-inbox/diagnostics') {
592
739
  const runs = await reportStore.list();
593
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir);
740
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
594
741
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
595
742
  res.end(JSON.stringify({
596
743
  sourceCoverage: idx.diagnosisSummary.sourceCoverage,
@@ -605,14 +752,14 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
605
752
  }));
606
753
  return;
607
754
  }
608
- if (path === '/api/observations/show') {
755
+ if (path === '/api/observe-inbox/show') {
609
756
  const id = parsed.searchParams.get('id') || '';
610
757
  const item = id ? findObservationInboxItem(id, observationsDir) : null;
611
758
  res.writeHead(item ? 200 : 404, { 'Content-Type': 'application/json; charset=utf-8' });
612
759
  res.end(JSON.stringify(item ? { id, text: formatObservationShow(item) } : { error: 'observation not found' }));
613
760
  return;
614
761
  }
615
- if (path === '/api/observations/review-state') {
762
+ if (path === '/api/observe-inbox/review-state') {
616
763
  if (req.method === 'GET') {
617
764
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
618
765
  res.end(JSON.stringify(loadObservationReviewState(observationsDir)));
@@ -667,7 +814,7 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
667
814
  if (doctorDetailMatch) {
668
815
  const id = decodeURIComponent(doctorDetailMatch[1]);
669
816
  const skillName = parsed.searchParams.get('skill') ?? '';
670
- const report = loadDoctorReport(doctorsDir, id, skillName || undefined);
817
+ const report = loadDoctorReport(doctorsDir, id, skillName || undefined, includeDoctorCards);
671
818
  if (!report) {
672
819
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
673
820
  res.end(lang === 'en' ? 'doctor report not found' : '体检报告不存在');
@@ -676,7 +823,7 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
676
823
  let ctx;
677
824
  if (skillName) {
678
825
  const runs = await reportStore.list();
679
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir);
826
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
680
827
  const entry = idx.entries.find((en) => en.skillName === skillName);
681
828
  if (entry)
682
829
  ctx = buildSkillContext(entry, 'doctor', id, idx.insightsBySkill.get(entry.skillName) ?? [], lang);
@@ -686,10 +833,10 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
686
833
  res.end(renderDoctorDetail(report, skillName, langQ, lang, ctx));
687
834
  return;
688
835
  }
689
- const analysisDetailMatch = path.match(/^\/analyses\/(.+)$/);
836
+ const analysisDetailMatch = path.match(/^\/observe-health\/(.+)$/);
690
837
  if (analysisDetailMatch) {
691
838
  const id = decodeURIComponent(analysisDetailMatch[1]);
692
- const report = loadAnalysis(analysesDir, id);
839
+ const report = loadAnalysis(analysesDir, id, includeObserveCards);
693
840
  if (!report) {
694
841
  res.writeHead(404, { 'Content-Type': 'text/plain' });
695
842
  res.end('analysis not found');
@@ -699,10 +846,10 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
699
846
  res.end(renderSkillHealthReport(report, lang));
700
847
  return;
701
848
  }
702
- const analysisApiMatch = path.match(/^\/api\/analyses\/(.+)$/);
849
+ const analysisApiMatch = path.match(/^\/api\/observe-health\/(.+)$/);
703
850
  if (analysisApiMatch) {
704
851
  const id = decodeURIComponent(analysisApiMatch[1]);
705
- const report = loadAnalysis(analysesDir, id);
852
+ const report = loadAnalysis(analysesDir, id, includeObserveCards);
706
853
  if (!report) {
707
854
  res.writeHead(404, { 'Content-Type': 'application/json' });
708
855
  res.end(JSON.stringify({ error: 'analysis not found' }));
@@ -716,14 +863,14 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
716
863
  if (skillTrendApiMatch) {
717
864
  const skillName = decodeURIComponent(skillTrendApiMatch[1]);
718
865
  res.writeHead(200, { 'Content-Type': 'application/json' });
719
- res.end(JSON.stringify(querySkillTrend(analysesDir, skillName)));
866
+ res.end(JSON.stringify(querySkillTrend(analysesDir, skillName, includeObserveCards)));
720
867
  return;
721
868
  }
722
869
  const skillTrendPageMatch = path.match(/^\/skill-trend\/(.+)$/);
723
870
  if (skillTrendPageMatch) {
724
871
  const skillName = decodeURIComponent(skillTrendPageMatch[1]);
725
872
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
726
- res.end(renderSkillTrendPage(querySkillTrend(analysesDir, skillName), lang));
873
+ res.end(renderSkillTrendPage(querySkillTrend(analysesDir, skillName, includeObserveCards), lang));
727
874
  return;
728
875
  }
729
876
  if (path === '/analyses-diff') {
@@ -734,7 +881,7 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
734
881
  res.end('missing from/to query params');
735
882
  return;
736
883
  }
737
- const diff = querySkillDiff(analysesDir, fromId, toId);
884
+ const diff = querySkillDiff(analysesDir, fromId, toId, includeObserveCards);
738
885
  if (!diff) {
739
886
  res.writeHead(404, { 'Content-Type': 'text/plain' });
740
887
  res.end('analysis not found');
@@ -752,7 +899,7 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
752
899
  res.end(JSON.stringify({ error: 'missing from/to query params' }));
753
900
  return;
754
901
  }
755
- const diff = querySkillDiff(analysesDir, fromId, toId);
902
+ const diff = querySkillDiff(analysesDir, fromId, toId, includeObserveCards);
756
903
  if (!diff) {
757
904
  res.writeHead(404, { 'Content-Type': 'application/json' });
758
905
  res.end(JSON.stringify({ error: 'analysis not found' }));
@@ -838,7 +985,7 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
838
985
  let ctx;
839
986
  if (report && report.kind === 'evaluation') {
840
987
  const runs = await reportStore.list();
841
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir);
988
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
842
989
  // 按 evalHistory 匹配(非仅最新),历史 eval 报告也能定位到所属 skill。
843
990
  const entry = idx.entries.find((en) => en.evalHistory.some((h) => h.reportId === reportId));
844
991
  if (entry)
@@ -860,14 +1007,14 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
860
1007
  // list renderer 直接消费,不再每请求 N×detectInsights 重算。
861
1008
  if (path === '/') {
862
1009
  const runs = await reportStore.list();
863
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir);
1010
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
864
1011
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
865
1012
  res.end(renderSkillList(idx, lang));
866
1013
  return;
867
1014
  }
868
1015
  if (path === '/api/skills') {
869
1016
  const runs = await reportStore.list();
870
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir);
1017
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
871
1018
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
872
1019
  res.end(JSON.stringify({
873
1020
  entries: idx.entries.map((entry) => ({
@@ -888,7 +1035,7 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
888
1035
  if (skillDiagnosticsApiMatch) {
889
1036
  const skillName = decodeURIComponent(skillDiagnosticsApiMatch[1]);
890
1037
  const runs = await reportStore.list();
891
- const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir);
1038
+ const idx = buildSkillIndex(runs, analysesDir, doctorsDir, observationsDir, { includeObserveCards, includeDoctorCards });
892
1039
  const diagnostics = idx.diagnosticsBySkill.get(skillName);
893
1040
  if (!diagnostics) {
894
1041
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -918,10 +1065,9 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
918
1065
  async function start() {
919
1066
  if (server)
920
1067
  return serverUrl;
921
- if (!existsSync(reportsDir))
922
- mkdirSync(reportsDir, { recursive: true });
923
- if (!existsSync(analysesDir))
924
- mkdirSync(analysesDir, { recursive: true });
1068
+ // reports / analyses / doctors 都不在启动时 mkdir —— studio 只读消费,readers 都 existsSync 守卫、
1069
+ // writer(omk eval / observe / doctor)各自建目录;reports 默认走 overlay(项目盖全局),启动时往随机
1070
+ // cwd 建空 .omk/reports 既无意义又是噪声。observationsDir / jobsDir 仍预建(既有行为)。
925
1071
  if (!existsSync(observationsDir))
926
1072
  mkdirSync(observationsDir, { recursive: true });
927
1073
  if (!existsSync(jobsDir))