oh-my-knowledge 0.35.0 → 0.36.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 (44) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/dist/assets/agent-skills/omk/SKILL.md +2 -2
  4. package/dist/assets/agent-skills/omk/references/commands.md +36 -0
  5. package/dist/authoring/evolver.js +1 -1
  6. package/dist/cli/commands/doctor.js +2 -1
  7. package/dist/cli/commands/eval/index.js +7 -6
  8. package/dist/cli/commands/list.d.ts +46 -0
  9. package/dist/cli/commands/list.js +243 -0
  10. package/dist/cli/lib/i18n-dict/list.d.ts +3 -0
  11. package/dist/cli/lib/i18n-dict/list.js +32 -0
  12. package/dist/cli/lib/i18n-dict.d.ts +2 -1
  13. package/dist/cli/lib/i18n-dict.js +2 -0
  14. package/dist/cli/lib/run-tally.js +1 -1
  15. package/dist/cli/lib/shared.js +1 -1
  16. package/dist/doctor/index.js +1 -1
  17. package/dist/eval-core/evaluation-reporting.d.ts +1 -0
  18. package/dist/eval-core/evaluation-reporting.js +6 -4
  19. package/dist/eval-workflows/batch-evaluation-workflow.js +3 -2
  20. package/dist/eval-workflows/run-evaluation.js +2 -2
  21. package/dist/inputs/skill-loader.js +6 -3
  22. package/dist/managed/index.d.ts +1 -0
  23. package/dist/managed/index.js +1 -0
  24. package/dist/managed/list-view.d.ts +55 -0
  25. package/dist/managed/list-view.js +66 -0
  26. package/dist/managed/store.js +45 -8
  27. package/dist/observability/experience.d.ts +2 -0
  28. package/dist/observability/experience.js +31 -2
  29. package/dist/observability/inbox.js +31 -5
  30. package/dist/observability/review-state.js +22 -11
  31. package/dist/observability/soft-standards/llm-extractor.js +3 -3
  32. package/dist/observability/soft-standards/skill-standards-store.d.ts +1 -0
  33. package/dist/observability/soft-standards/skill-standards-store.js +77 -14
  34. package/dist/observability/soft-standards/types.d.ts +2 -2
  35. package/dist/renderer/html-renderer.js +3 -3
  36. package/dist/server/report-server.js +1 -1
  37. package/dist/server/report-store.d.ts +1 -1
  38. package/dist/server/report-store.js +16 -15
  39. package/dist/server/skill-index.js +5 -4
  40. package/dist/types/doctor.d.ts +2 -2
  41. package/dist/types/doctor.js +1 -1
  42. package/dist/types/observability.d.ts +6 -6
  43. package/dist/types/report.d.ts +9 -4
  44. package/package.json +1 -1
@@ -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,4 @@
4
4
  */
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
+ export * from './list-view.js';
@@ -4,3 +4,4 @@
4
4
  */
5
5
  export * from './store.js';
6
6
  export * from './evidence.js';
7
+ export * from './list-view.js';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `omk list` 的纯视图构造(#203 管理支柱)—— 把受管记录摊成可渲染的行,不做任何 IO。
3
+ *
4
+ * 「当前源探测」由调用方注入(`probeOf`):CLI 侧重物化真源算哈,单测侧给 mock —— 保持本模块纯函数、
5
+ * 可测。探测是**三态**:`reachable:true + hash`(算出了当前哈,可判 drift)/ `reachable:false`(源不可达 /
6
+ * 解析失败 / 拒绝读取)。**不可达 ≠ 已 drift** —— 把「这里查不了」当「内容变了」会对从别处看的本地 git 记录
7
+ * 误报 stale(locator 随 cwd 漂),所以不可达时只按证据给 installed/measurable、不打 drift、单独标「未核」。
8
+ * reachable 时才走 `deriveManagedState`(哈不等 → stale)。verdict / 可比性取**当前有效证据**
9
+ * (contentHash == record.contentHash)里 recordedAt 最新那条 —— 旧内容的证据不冒充当前。
10
+ */
11
+ import type { ArtifactKind, ManagedArtifactRecord, ManagedLifecycleLabel } from '../types/index.js';
12
+ /** 当前源探测结果(三态)。`reachable:false` = 不可达 / 解析失败 / 拒读,**不等于**已 drift。 */
13
+ export interface SourceProbe {
14
+ reachable: boolean;
15
+ /** 当前源整树 / 单文件哈;仅 reachable 时有。 */
16
+ hash?: string;
17
+ }
18
+ export interface ManagedListRow {
19
+ id: string;
20
+ name: string;
21
+ /** artifact 类型,直取自记录(裸 kind 留给 ArtifactKind,见 terminology-spec §5.4)。 */
22
+ kind: ArtifactKind;
23
+ sourceKind: 'file' | 'git';
24
+ /** 展示用源标识:仅远端 git(sourceKind==='git' 且带 url)显 url,其余一律显 locator —— 保证「显示的」
25
+ * 就是「被 probe / 读取的」路径,file 源即便混入 url(validator 已拒)也不显示它。 */
26
+ sourceLabel: string;
27
+ state: ManagedLifecycleLabel;
28
+ drifted: boolean;
29
+ /** 当前源是否被成功核对过 hash。false = 不可达/拒读 → drift 未核(不据此判 stale)。 */
30
+ reachable: boolean;
31
+ /** 当前有效证据(若有)里 recordedAt 最新那条的 verdict。 */
32
+ latestVerdict?: string;
33
+ /** 该证据的可比性 marker —— 跨报告比 verdict 前需一致。 */
34
+ comparability?: {
35
+ cliVersion: string;
36
+ judgePromptHash?: string;
37
+ debiasMode?: Array<'length' | 'position'>;
38
+ };
39
+ /** 最新当前证据的记录时间。 */
40
+ recordedAt?: string;
41
+ /** 当前有效证据数 / 全部证据数(含旧内容的历史证据)。 */
42
+ currentEvidenceCount: number;
43
+ totalEvidenceCount: number;
44
+ distributionCount: number;
45
+ }
46
+ export declare function buildManagedListRow(record: ManagedArtifactRecord, probe: SourceProbe): ManagedListRow;
47
+ /**
48
+ * 组装全部行。`probeOf` 给每条记录探测当前源(三态,见文件头)。
49
+ * 按 name 排序(稳定、可读);name collation 相等再按 kind。排序对机读出口(--json)也生效,故:
50
+ * - **钉死 locale**(`'en'`):缺省 locale 随宿主 LANG / LC_COLLATE 漂,会让两台机器对同一批记录排出不同
51
+ * JSON 顺序,破坏 omk「确定、可比」的底色。
52
+ * - 平级判定用**同一 collation 度量**(localeCompare 结果是否为 0)而非 `===`:NFC / NFD 等价的同显示名
53
+ * 在 `===` 下不等、却 collation 相等,用 `===` 会漏掉 kind 平级 tiebreak、令顺序退回 readdir 序(不定)。
54
+ */
55
+ export declare function buildManagedListRows(records: ManagedArtifactRecord[], probeOf: (record: ManagedArtifactRecord) => SourceProbe): ManagedListRow[];
@@ -0,0 +1,66 @@
1
+ import { deriveManagedState } from './store.js';
2
+ function latestCurrentEvidence(record) {
3
+ const current = record.evidence.filter((e) => e.contentHash === record.contentHash);
4
+ if (current.length === 0)
5
+ return undefined;
6
+ // 取 recordedAt 最新的一条。omk 自写恒 UTC `Z`、字典序即时间序;但记录可手改 / 随仓库分发,异偏移
7
+ // 或异精度的 ISO 串字典序会乱 → 优先按解析后的真实时刻比,两端都可解析才用;否则退回字典序(不劣化
8
+ // omk 自写场景)。并列取后出现的。
9
+ const ms = (s) => { const n = Date.parse(s); return Number.isNaN(n) ? null : n; };
10
+ return current.reduce((a, b) => {
11
+ const ta = ms(a.recordedAt);
12
+ const tb = ms(b.recordedAt);
13
+ if (ta !== null && tb !== null)
14
+ return tb >= ta ? b : a;
15
+ return b.recordedAt >= a.recordedAt ? b : a;
16
+ });
17
+ }
18
+ export function buildManagedListRow(record, probe) {
19
+ const currentEvidenceCount = record.evidence.filter((e) => e.contentHash === record.contentHash).length;
20
+ let state;
21
+ let drifted;
22
+ if (probe.reachable) {
23
+ // 算出了当前哈:正常推导(哈不等 → stale/drift)。
24
+ const d = deriveManagedState({ record, currentContentHash: probe.hash });
25
+ state = d.label;
26
+ drifted = d.drifted;
27
+ }
28
+ else {
29
+ // 不可达:不据此判 stale —— 只按证据给 installed/measurable,drift 标未核。
30
+ state = currentEvidenceCount > 0 ? 'measurable' : 'installed';
31
+ drifted = false;
32
+ }
33
+ const latest = latestCurrentEvidence(record);
34
+ return {
35
+ id: record.id,
36
+ name: record.name,
37
+ kind: record.kind,
38
+ sourceKind: record.source.sourceKind,
39
+ sourceLabel: record.source.sourceKind === 'git' && record.source.url ? record.source.url : record.source.locator,
40
+ state,
41
+ drifted,
42
+ reachable: probe.reachable,
43
+ ...(latest?.verdict ? { latestVerdict: latest.verdict } : {}),
44
+ ...(latest?.comparability ? { comparability: latest.comparability } : {}),
45
+ ...(latest?.recordedAt ? { recordedAt: latest.recordedAt } : {}),
46
+ currentEvidenceCount,
47
+ totalEvidenceCount: record.evidence.length,
48
+ distributionCount: record.distribution.length,
49
+ };
50
+ }
51
+ /**
52
+ * 组装全部行。`probeOf` 给每条记录探测当前源(三态,见文件头)。
53
+ * 按 name 排序(稳定、可读);name collation 相等再按 kind。排序对机读出口(--json)也生效,故:
54
+ * - **钉死 locale**(`'en'`):缺省 locale 随宿主 LANG / LC_COLLATE 漂,会让两台机器对同一批记录排出不同
55
+ * JSON 顺序,破坏 omk「确定、可比」的底色。
56
+ * - 平级判定用**同一 collation 度量**(localeCompare 结果是否为 0)而非 `===`:NFC / NFD 等价的同显示名
57
+ * 在 `===` 下不等、却 collation 相等,用 `===` 会漏掉 kind 平级 tiebreak、令顺序退回 readdir 序(不定)。
58
+ */
59
+ export function buildManagedListRows(records, probeOf) {
60
+ return records
61
+ .map((r) => buildManagedListRow(r, probeOf(r)))
62
+ .sort((a, b) => {
63
+ const byName = a.name.localeCompare(b.name, 'en');
64
+ return byName !== 0 ? byName : a.kind.localeCompare(b.kind, 'en');
65
+ });
66
+ }
@@ -31,9 +31,16 @@ export { hashArtifactSource, isDistributablePath, distributableCopyFilter } from
31
31
  function isStringField(v) {
32
32
  return typeof v === 'string';
33
33
  }
34
- // 校验到元素级:记录文件是用户可手改的(透明性是设计价值),`evidence:[null]` / 缺字段的 distribution
35
- // 都是现实的坏手改;若只查 Array.isArray,坏元素会在 deriveManagedState / mergeManagedRecord 里
36
- // 解引用崩溃(且在 load 的 try/catch 之外)。这里直接判脏 → load 丢弃该文件,消费方永远拿不到半成品。
34
+ /** 仅当字段缺省或是 string 才放行(可选 string 字段的脏值守卫,如 source.url / evidence.verdict)。 */
35
+ function isOptionalString(v) {
36
+ return v === undefined || typeof v === 'string';
37
+ }
38
+ // 受管记录可安装的 kind(managed 记录绝不是 baseline)。
39
+ const MANAGED_KINDS = new Set(['skill', 'prompt', 'agent', 'workflow']);
40
+ // 校验到**运行时实际收窄**的边界,不止「字段是 string」:记录文件是用户可手改、且可能随仓库分发(被
41
+ // loadAllManagedRecords 无 opt-in 读到)的不可信输入。若只查 string,畸形记录(如 source.url 是对象、
42
+ // sourceKind 是任意串)会绕过 validator,在下游(omk list 的 sourceLabel ?? → dispWidth(对象) 等)
43
+ // 抛 TypeError 让命令崩溃。这里把 source / evidence 收窄到 list 等消费方实际解引用的类型,判脏即丢弃该文件。
37
44
  function isManagedArtifactRecord(value) {
38
45
  if (!value || typeof value !== 'object')
39
46
  return false;
@@ -42,19 +49,49 @@ function isManagedArtifactRecord(value) {
42
49
  && r.schemaVersion === 2
43
50
  && isStringField(r.id)
44
51
  && isStringField(r.name)
45
- && isStringField(r.kind)
52
+ && isStringField(r.kind) && MANAGED_KINDS.has(r.kind)
46
53
  && isStringField(r.contentHash)
47
54
  && r.source && typeof r.source === 'object'
48
- && isStringField(r.source.locator)
49
- && isStringField(r.source.sourceKind)
50
55
  && Array.isArray(r.distribution)
51
56
  && Array.isArray(r.evidence)
52
57
  && Array.isArray(r.decisions)))
53
58
  return false;
59
+ const src = r.source;
60
+ if (!(isStringField(src.locator)
61
+ && (src.sourceKind === 'file' || src.sourceKind === 'git')
62
+ && typeof src.isDirectorySkill === 'boolean'
63
+ && isOptionalString(src.url)
64
+ && isOptionalString(src.ref)))
65
+ return false;
66
+ // url / ref 是 git-only 字段。file 源带 url 时,list 的 sourceLabel 会显示假 url、掩盖真实被 probe /
67
+ // 读取的 locator(畸形但可通过上面 string 校验)→ 「读了什么」与「显示什么」不一致。判脏丢弃。
68
+ if (src.sourceKind === 'file' && (src.url !== undefined || src.ref !== undefined))
69
+ return false;
54
70
  const okDist = r.distribution.every((d) => d && typeof d === 'object'
55
71
  && isStringField(d.path) && isStringField(d.contentHash));
56
- const okEv = r.evidence.every((e) => e && typeof e === 'object'
57
- && isStringField(e.reportId) && isStringField(e.contentHash));
72
+ const okEv = r.evidence.every((e) => {
73
+ if (!e || typeof e !== 'object')
74
+ return false;
75
+ const ev = e;
76
+ if (!(isStringField(ev.reportId) && isStringField(ev.contentHash) && isStringField(ev.recordedAt)))
77
+ return false;
78
+ if (!isOptionalString(ev.verdict))
79
+ return false;
80
+ // comparability 若存在:必须是带 string cliVersion 的对象(list / promote 会读它)。可选 marker
81
+ // judgePromptHash / debiasMode 同样收窄到声明类型 —— 否则任意类型脏值会穿过 validator 原样进 `omk list
82
+ // --json`(及未来 promote gate)消费方。
83
+ if (ev.comparability !== undefined) {
84
+ const c = ev.comparability;
85
+ if (!c || typeof c !== 'object' || !isStringField(c.cliVersion))
86
+ return false;
87
+ if (!isOptionalString(c.judgePromptHash))
88
+ return false;
89
+ if (c.debiasMode !== undefined
90
+ && !(Array.isArray(c.debiasMode) && c.debiasMode.every((m) => m === 'length' || m === 'position')))
91
+ return false;
92
+ }
93
+ return true;
94
+ });
58
95
  const okDec = r.decisions.every((d) => d && typeof d === 'object'
59
96
  && isStringField(d.decisionKind));
60
97
  return okDist && okEv && okDec;
@@ -4,6 +4,7 @@ import type { SkillSegment } from './trace-segmenter.js';
4
4
  export { findNegativeFeedbackMatches, findPositiveFeedbackMatches, findUserCorrectionMatches, findUserGoalShiftMatches, hasNegativeFeedbackSignal, hasPositiveFeedbackSignal, hasUserCorrectionSignal, hasUserGoalShiftSignal, } from './feedback-matchers.js';
5
5
  export type { TextMatchRange } from './feedback-matchers.js';
6
6
  export type { ExperienceAssistiveInference, ExperienceAssistiveInferenceCautionCode, ExperienceAssistiveInferenceCode, ExperienceAssistiveInferenceConfidence, ExperienceChecklistContribution, ExperienceChecklistItem, ExperienceChecklistItemStatus, ExperienceEpisode, ExperienceEpisodeArtifact, ExperienceEpisodeArtifactKind, ExperienceEpisodeBoundaryReason, ExperienceEpisodeOutcome, ExperienceEpisodeRole, ExperienceEvidenceChain, ExperienceEvidenceKind, ExperienceEvidenceRef, ExperienceFeedbackAttribution, ExperienceFeedbackAttributionReason, ExperienceFeedbackAttributionRole, ExperienceFeedbackSignal, ExperienceFeedbackSignalType, ExperienceGoalEvidenceRef, ExperienceGoalSlice, ExperienceGoalSliceReasonCode, ExperienceInvocation, ExperienceMessageRange, ExperienceOrchestrationEdge, ExperienceOrchestrationEdgeKind, ExperienceOrchestrationEdgeStatus, ExperienceOutcomeClosure, ExperienceParentReason, ExperienceReviewBasisCode, ExperienceReviewIndicators, ExperienceReviewPriority, ExperienceReviewerReport, ExperienceReviewerReportFinding, ExperienceReviewerReportFindingLevel, ExperienceReviewerReportFindingSource, ExperienceReviewerReportScope, ExperienceReviewerReportStep, ExperienceReviewerReportStepStatus, ExperienceRuleFinding, ExperienceRuleFindingCode, ExperienceRuleFindingLevel, ExperienceRuntimeSkillType, ExperienceRuntimeSkillTypeSource, ExperienceSessionStory, ExperienceSessionStoryAnswer, ExperienceSessionStoryAnswerKey, ExperienceSessionStoryGoalSlice, ExperienceSessionStoryGraphEdge, ExperienceSessionStoryGraphNode, ExperienceSessionStoryNode, ExperienceSessionStoryNodeKind, ExperienceSessionStorySkillLink, ExperienceSessionStorySkillRole, ExperienceSessionStorySubagentDispatch, ExperienceSessionSummary, ExperienceSkillSegment, ExperienceSkillSummary, ExperienceTimelineBranch, ExperienceTimelineEvent, ExperienceTimelineTree, ObservationExperienceReport, ObservationReviewState, };
7
+ export declare const OBSERVATION_EXPERIENCE_SCHEMA_VERSION = 2;
7
8
  export declare function aggregateExperienceChecklistItemStatus(statuses: ExperienceChecklistItemStatus[]): ExperienceChecklistItemStatus;
8
9
  interface BuildExperienceInput {
9
10
  sessions: CcSession[];
@@ -13,6 +14,7 @@ interface BuildExperienceInput {
13
14
  reviewState?: ObservationReviewState;
14
15
  }
15
16
  export declare function buildObservationExperienceReport(input: BuildExperienceInput): ObservationExperienceReport;
17
+ export declare function normalizeObservationExperienceReport(value: unknown): ObservationExperienceReport | null;
16
18
  export declare function isExperienceTraceInProgress(session: ExperienceSessionSummary): boolean;
17
19
  export declare function hasRecognizableUserGoalText(value: string | undefined): boolean;
18
20
  export declare function foldExperienceChecklistItems(items: ExperienceChecklistItem[]): {
@@ -7,6 +7,7 @@ import { durationMsBetween } from '../shared/time.js';
7
7
  import { loadExpectedToolsForSkill, loadFrontmatterSkillType, loadSkillDeclarationCheck, } from './experience-frontmatter.js';
8
8
  import { findNegativeFeedbackMatches, findPositiveFeedbackMatches, findUserCorrectionMatches, findUserGoalShiftMatches, hasNegativeFeedbackSignal, hasPositiveFeedbackSignal, hasUserCorrectionSignal, hasUserGoalShiftSignal, } from './feedback-matchers.js';
9
9
  export { findNegativeFeedbackMatches, findPositiveFeedbackMatches, findUserCorrectionMatches, findUserGoalShiftMatches, hasNegativeFeedbackSignal, hasPositiveFeedbackSignal, hasUserCorrectionSignal, hasUserGoalShiftSignal, } from './feedback-matchers.js';
10
+ export const OBSERVATION_EXPERIENCE_SCHEMA_VERSION = 2;
10
11
  export function aggregateExperienceChecklistItemStatus(statuses) {
11
12
  if (statuses.includes('degraded'))
12
13
  return 'degraded';
@@ -131,8 +132,8 @@ export function buildObservationExperienceReport(input) {
131
132
  const sessions = summarizeExperienceSessions(invocations, sessionGroupsByKey, input.generatedAt, input.reviewState);
132
133
  const skills = summarizeExperienceSkills(sessions, invocations);
133
134
  return {
134
- reportKind: 'observe-experience',
135
- schemaVersion: 1,
135
+ kind: 'observe-experience',
136
+ schemaVersion: OBSERVATION_EXPERIENCE_SCHEMA_VERSION,
136
137
  scope: 'evidence-only',
137
138
  generatedAt: input.generatedAt,
138
139
  meta: {
@@ -148,6 +149,34 @@ export function buildObservationExperienceReport(input) {
148
149
  skills,
149
150
  };
150
151
  }
152
+ export function normalizeObservationExperienceReport(value) {
153
+ if (!value || typeof value !== 'object')
154
+ return null;
155
+ const report = value;
156
+ const kind = report.kind === 'observe-experience' ? report.kind : null;
157
+ if (!kind)
158
+ return null;
159
+ if (report.schemaVersion !== OBSERVATION_EXPERIENCE_SCHEMA_VERSION)
160
+ return null;
161
+ if (report.scope !== 'evidence-only')
162
+ return null;
163
+ if (typeof report.generatedAt !== 'string' || !report.meta || typeof report.meta !== 'object')
164
+ return null;
165
+ if (!Array.isArray(report.goalSlices) || !Array.isArray(report.invocations) || !Array.isArray(report.sessions) || !Array.isArray(report.skills)) {
166
+ return null;
167
+ }
168
+ return {
169
+ kind: 'observe-experience',
170
+ schemaVersion: OBSERVATION_EXPERIENCE_SCHEMA_VERSION,
171
+ scope: 'evidence-only',
172
+ generatedAt: report.generatedAt,
173
+ meta: report.meta,
174
+ goalSlices: report.goalSlices,
175
+ invocations: report.invocations,
176
+ sessions: report.sessions,
177
+ skills: report.skills,
178
+ };
179
+ }
151
180
  function relatedObservationItems(segment, items) {
152
181
  return items.filter((item) => item.skillName === segment.skillName
153
182
  && (item.sessionId === segment.sessionId || item.recentSessionIds.includes(segment.sessionId))
@@ -6,10 +6,11 @@ import { extractGapSignalsFromTrace } from '../analysis/gap-analyzer.js';
6
6
  import { ccTracesToResultEntries } from './trace-adapter.js';
7
7
  import { isSearchToolCall, toolCallQuery } from '../shared/tool-search.js';
8
8
  import { durationMsBetween } from '../shared/time.js';
9
- import { buildObservationExperienceReport } from './experience.js';
9
+ import { buildObservationExperienceReport, normalizeObservationExperienceReport, } from './experience.js';
10
10
  export const DEFAULT_PROJECT_OBSERVATIONS_DIR = join(process.cwd(), '.omk', 'observations');
11
11
  export const DEFAULT_GLOBAL_OBSERVATIONS_DIR = join(homedir(), '.oh-my-knowledge', 'observations');
12
12
  export const DEFAULT_OBSERVATIONS_DIR = DEFAULT_PROJECT_OBSERVATIONS_DIR;
13
+ const OBSERVATION_INBOX_SCHEMA_VERSION = 2;
13
14
  function hashString(input) {
14
15
  return createHash('sha256').update(input).digest('hex').slice(0, 16);
15
16
  }
@@ -432,8 +433,8 @@ export function buildObservationInboxReport(tracePath, options = {}) {
432
433
  const items = finishInboxAggregation(aggregationState);
433
434
  const experience = buildObservationExperienceReport({ sessions, segments, items, generatedAt, reviewState: options.reviewState });
434
435
  const report = {
435
- reportKind: 'observe-inbox',
436
- schemaVersion: 1,
436
+ kind: 'observe-inbox',
437
+ schemaVersion: OBSERVATION_INBOX_SCHEMA_VERSION,
437
438
  meta: {
438
439
  tracePath,
439
440
  generatedAt,
@@ -543,7 +544,9 @@ export function loadObservationInboxReports(dir = DEFAULT_OBSERVATIONS_DIR) {
543
544
  .filter((file) => file.endsWith('-observe-inbox.json'))
544
545
  .map((file) => {
545
546
  try {
546
- const report = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
547
+ const report = normalizeObservationInboxReport(JSON.parse(readFileSync(join(dir, file), 'utf-8')));
548
+ if (!report)
549
+ return null;
547
550
  report.items = report.items.map((item) => {
548
551
  const sourceKind = item.sourceKind;
549
552
  return {
@@ -567,7 +570,30 @@ export function loadObservationInboxReports(dir = DEFAULT_OBSERVATIONS_DIR) {
567
570
  return null;
568
571
  }
569
572
  })
570
- .filter((r) => r?.reportKind === 'observe-inbox');
573
+ .filter((r) => r?.kind === 'observe-inbox');
574
+ }
575
+ function normalizeObservationInboxReport(value) {
576
+ if (!value || typeof value !== 'object')
577
+ return null;
578
+ const report = value;
579
+ const kind = report.kind === 'observe-inbox' ? report.kind : null;
580
+ if (!kind)
581
+ return null;
582
+ if (report.schemaVersion !== OBSERVATION_INBOX_SCHEMA_VERSION)
583
+ return null;
584
+ if (!report.meta || typeof report.meta !== 'object' || !Array.isArray(report.items))
585
+ return null;
586
+ const experience = report.experience === undefined
587
+ ? undefined
588
+ : normalizeObservationExperienceReport(report.experience) ?? undefined;
589
+ return {
590
+ kind: 'observe-inbox',
591
+ schemaVersion: OBSERVATION_INBOX_SCHEMA_VERSION,
592
+ meta: report.meta,
593
+ items: report.items,
594
+ ...(experience ? { experience } : {}),
595
+ ...(report.diagnostics !== undefined ? { diagnostics: report.diagnostics } : {}),
596
+ };
571
597
  }
572
598
  export function loadLatestObservationInboxReport(dir = DEFAULT_OBSERVATIONS_DIR) {
573
599
  const reports = loadObservationInboxReports(dir);
@@ -38,10 +38,11 @@ export function observationMetricAnnotationVerdict(state, ref, metricKey) {
38
38
  export function observationReviewStatePath(observationsDir) {
39
39
  return join(observationsDir, 'review-state.json');
40
40
  }
41
+ const OBSERVATION_REVIEW_STATE_SCHEMA_VERSION = 2;
41
42
  export function emptyObservationReviewState(now = new Date().toISOString()) {
42
43
  return {
43
- reportKind: 'observe-review-state',
44
- schemaVersion: 1,
44
+ kind: 'observe-review-state',
45
+ schemaVersion: OBSERVATION_REVIEW_STATE_SCHEMA_VERSION,
45
46
  updatedAt: now,
46
47
  entries: {},
47
48
  };
@@ -52,15 +53,7 @@ export function loadObservationReviewState(observationsDir) {
52
53
  return emptyObservationReviewState();
53
54
  try {
54
55
  const parsed = JSON.parse(readFileSync(path, 'utf-8'));
55
- if (parsed.reportKind !== 'observe-review-state' || parsed.schemaVersion !== 1 || !parsed.entries || typeof parsed.entries !== 'object') {
56
- return emptyObservationReviewState();
57
- }
58
- return {
59
- reportKind: 'observe-review-state',
60
- schemaVersion: 1,
61
- updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
62
- entries: Object.fromEntries(Object.entries(parsed.entries).filter(([, entry]) => isReviewStateEntry(entry))),
63
- };
56
+ return normalizeObservationReviewState(parsed) ?? emptyObservationReviewState();
64
57
  }
65
58
  catch {
66
59
  return emptyObservationReviewState();
@@ -92,6 +85,24 @@ export function updateObservationReviewState(observationsDir, update, now = new
92
85
  writeFileSync(observationReviewStatePath(observationsDir), JSON.stringify(state, null, 2));
93
86
  return state;
94
87
  }
88
+ function normalizeObservationReviewState(value) {
89
+ if (!value || typeof value !== 'object')
90
+ return null;
91
+ const parsed = value;
92
+ const kind = parsed.kind === 'observe-review-state' ? parsed.kind : null;
93
+ if (!kind)
94
+ return null;
95
+ if (parsed.schemaVersion !== OBSERVATION_REVIEW_STATE_SCHEMA_VERSION)
96
+ return null;
97
+ if (!parsed.entries || typeof parsed.entries !== 'object')
98
+ return null;
99
+ return {
100
+ kind: 'observe-review-state',
101
+ schemaVersion: OBSERVATION_REVIEW_STATE_SCHEMA_VERSION,
102
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
103
+ entries: Object.fromEntries(Object.entries(parsed.entries).filter(([, entry]) => isReviewStateEntry(entry))),
104
+ };
105
+ }
95
106
  export function deleteObservationReviewState(observationsDir, targetType, targetId, now = new Date().toISOString()) {
96
107
  if (!isReviewTargetType(targetType))
97
108
  throw new Error('invalid review targetType');
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { createExecutor } from '../../executors/index.js';
3
3
  import { readPromptDocument } from '../../shared/llm-prompts/index.js';
4
4
  import { DEFAULT_LLM_ENHANCED_REVIEW_MODEL, PROMPTS_DIR, SOFT_STANDARD_PROMPT_ID, SOFT_STANDARD_PROMPT_VERSION, } from './constants.js';
5
- import { hashText, loadExisting, markStale, skillDerivedStandardsDir, skillDerivedStandardsPath, } from './skill-standards-store.js';
5
+ import { hashText, loadExisting, markStale, SKILL_DERIVED_STANDARDS_SCHEMA_VERSION, skillDerivedStandardsDir, skillDerivedStandardsPath, } from './skill-standards-store.js';
6
6
  import { evaluateRuntimeStandardNodes } from './runtime-evaluator.js';
7
7
  export async function extractSkillSoftStandards(options) {
8
8
  const { observationsDir, skillChain } = options;
@@ -47,8 +47,8 @@ export async function extractSkillSoftStandards(options) {
47
47
  source: 'llm_soft_standard',
48
48
  }));
49
49
  const record = {
50
- reportKind: 'observe-skill-derived-standards',
51
- schemaVersion: 1,
50
+ kind: 'observe-skill-derived-standards',
51
+ schemaVersion: SKILL_DERIVED_STANDARDS_SCHEMA_VERSION,
52
52
  skillName: skillChain.skillName,
53
53
  sourceSkillPath: skillChain.definition.path,
54
54
  sourceHash,
@@ -1,4 +1,5 @@
1
1
  import type { ResolveSkillStandardsOptions, ResolvedSkillStandards, SkillDerivedStandardStatus, SkillDerivedStandards } from './types.js';
2
+ export declare const SKILL_DERIVED_STANDARDS_SCHEMA_VERSION = 2;
2
3
  export declare function skillDerivedStandardsDir(observationsDir: string): string;
3
4
  export declare function skillDerivedStandardsPath(observationsDir: string, skillName: string): string;
4
5
  export declare function loadSkillDerivedStandards(observationsDir: string): Record<string, SkillDerivedStandards>;
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { buildObservationSkillChain } from '../skill-chain.js';
5
+ export const SKILL_DERIVED_STANDARDS_SCHEMA_VERSION = 2;
5
6
  export function skillDerivedStandardsDir(observationsDir) {
6
7
  return join(observationsDir, 'skill-derived');
7
8
  }
@@ -17,8 +18,8 @@ export function loadSkillDerivedStandards(observationsDir) {
17
18
  if (!file.endsWith('.json'))
18
19
  continue;
19
20
  try {
20
- const parsed = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
21
- if (isSkillDerivedStandards(parsed))
21
+ const parsed = normalizeSkillDerivedStandards(JSON.parse(readFileSync(join(dir, file), 'utf-8')));
22
+ if (parsed)
22
23
  out[parsed.skillName] = parsed;
23
24
  }
24
25
  catch {
@@ -51,11 +52,13 @@ export function updateSkillDerivedStandardStatus(observationsDir, skillName, sta
51
52
  }
52
53
  export function resolveSkillStandards(skillName, options) {
53
54
  const skillChain = options.skillChain ?? buildObservationSkillChain(skillName, options.cwd ?? process.cwd());
54
- const derived = options.derivedStandards
55
- ? isSkillDerivedStandards(options.derivedStandards)
56
- ? options.derivedStandards
57
- : options.derivedStandards[skillName]
58
- : loadSkillDerivedStandards(options.observationsDir)[skillName];
55
+ const directDerived = normalizeSkillDerivedStandards(options.derivedStandards);
56
+ const mappedDerived = options.derivedStandards && !isSkillDerivedStandards(options.derivedStandards)
57
+ ? normalizeSkillDerivedStandards(options.derivedStandards[skillName])
58
+ : undefined;
59
+ const derived = directDerived
60
+ ?? mappedDerived
61
+ ?? loadSkillDerivedStandards(options.observationsDir)[skillName];
59
62
  const active = [];
60
63
  const candidates = [];
61
64
  const hasFrontmatterHardRules = skillChain.healthCheck.hardRules.declared && skillChain.healthCheck.hardRules.rules.length > 0;
@@ -112,8 +115,7 @@ export function loadExisting(path) {
112
115
  if (!existsSync(path))
113
116
  return undefined;
114
117
  try {
115
- const parsed = JSON.parse(readFileSync(path, 'utf-8'));
116
- return isSkillDerivedStandards(parsed) ? parsed : undefined;
118
+ return normalizeSkillDerivedStandards(JSON.parse(readFileSync(path, 'utf-8')));
117
119
  }
118
120
  catch {
119
121
  return undefined;
@@ -129,13 +131,74 @@ export function markStale(record, generatedAt) {
129
131
  };
130
132
  }
131
133
  export function isSkillDerivedStandards(value) {
134
+ return normalizeSkillDerivedStandards(value) !== undefined;
135
+ }
136
+ function normalizeSkillDerivedStandards(value) {
132
137
  if (!value || typeof value !== 'object')
133
- return false;
138
+ return undefined;
134
139
  const item = value;
135
- return item.reportKind === 'observe-skill-derived-standards'
136
- && item.schemaVersion === 1
137
- && typeof item.skillName === 'string'
138
- && Array.isArray(item.standards);
140
+ const kind = item.kind === 'observe-skill-derived-standards' ? item.kind : null;
141
+ if (!kind)
142
+ return undefined;
143
+ if (item.schemaVersion !== SKILL_DERIVED_STANDARDS_SCHEMA_VERSION)
144
+ return undefined;
145
+ if (typeof item.skillName !== 'string' || typeof item.generatedAt !== 'string' || typeof item.model !== 'string' || typeof item.executor !== 'string') {
146
+ return undefined;
147
+ }
148
+ if (typeof item.promptId !== 'string' || typeof item.promptVersion !== 'string' || !Array.isArray(item.standards))
149
+ return undefined;
150
+ const standards = item.standards
151
+ .map(normalizeSkillDerivedStandard)
152
+ .filter((record) => record !== null);
153
+ return {
154
+ kind: 'observe-skill-derived-standards',
155
+ schemaVersion: SKILL_DERIVED_STANDARDS_SCHEMA_VERSION,
156
+ skillName: item.skillName,
157
+ ...(typeof item.sourceSkillPath === 'string' ? { sourceSkillPath: item.sourceSkillPath } : {}),
158
+ ...(typeof item.sourceHash === 'string' ? { sourceHash: item.sourceHash } : {}),
159
+ generatedAt: item.generatedAt,
160
+ model: item.model,
161
+ executor: item.executor,
162
+ promptId: item.promptId,
163
+ promptVersion: item.promptVersion,
164
+ ...(typeof item.promptHash === 'string' ? { promptHash: item.promptHash } : {}),
165
+ ...(typeof item.runtimeEvidenceHash === 'string' ? { runtimeEvidenceHash: item.runtimeEvidenceHash } : {}),
166
+ ...(item.enhancedReview && typeof item.enhancedReview === 'object' ? { enhancedReview: item.enhancedReview } : {}),
167
+ standards,
168
+ };
169
+ }
170
+ function normalizeSkillDerivedStandard(value) {
171
+ if (!value || typeof value !== 'object')
172
+ return null;
173
+ const item = value;
174
+ const standardKind = item.standardKind === 'hard_rule_candidate' || item.standardKind === 'workflow_candidate'
175
+ ? item.standardKind
176
+ : item.kind === 'hard_rule_candidate' || item.kind === 'workflow_candidate'
177
+ ? item.kind
178
+ : null;
179
+ if (!standardKind)
180
+ return null;
181
+ if (typeof item.id !== 'string' || typeof item.title !== 'string' || typeof item.body !== 'string')
182
+ return null;
183
+ if (item.status !== 'pending_review' && item.status !== 'author_confirmed' && item.status !== 'rejected' && item.status !== 'stale') {
184
+ return null;
185
+ }
186
+ if (item.source !== 'llm_soft_standard')
187
+ return null;
188
+ if (item.confidence !== 'low' && item.confidence !== 'medium' && item.confidence !== 'high')
189
+ return null;
190
+ if (!Array.isArray(item.evidence) || item.evidence.some((entry) => typeof entry !== 'string'))
191
+ return null;
192
+ return {
193
+ id: item.id,
194
+ standardKind,
195
+ status: item.status,
196
+ title: item.title,
197
+ body: item.body,
198
+ source: item.source,
199
+ confidence: item.confidence,
200
+ evidence: item.evidence,
201
+ };
139
202
  }
140
203
  function candidateRank(status) {
141
204
  if (status === 'pending_review')
@@ -14,8 +14,8 @@ export interface SkillDerivedStandard {
14
14
  evidence: string[];
15
15
  }
16
16
  export interface SkillDerivedStandards {
17
- reportKind: 'observe-skill-derived-standards';
18
- schemaVersion: 1;
17
+ kind: 'observe-skill-derived-standards';
18
+ schemaVersion: 2;
19
19
  skillName: string;
20
20
  sourceSkillPath?: string;
21
21
  sourceHash?: string;
@@ -21,7 +21,7 @@ function levelDot(level) {
21
21
  }
22
22
  }
23
23
  function isEvaluationReport(document) {
24
- return document.reportKind === 'evaluation';
24
+ return document.kind === 'evaluation';
25
25
  }
26
26
  function scoreOf(summary) {
27
27
  return summary?.avgCompositeScore ?? summary?.avgLlmScore ?? null;
@@ -163,7 +163,7 @@ export function renderRunList(runs, lang = DEFAULT_LANG) {
163
163
  `<span class="rc-score-num rc-score-num--${cls}">${score.toFixed(2)}</span></div>`;
164
164
  };
165
165
  const cards = runs.map((run) => {
166
- if (run.reportKind === 'batch-evaluation') {
166
+ if (run.kind === 'batch-evaluation') {
167
167
  const m = run.meta;
168
168
  const scores = run.items.length > 0
169
169
  ? run.items.map((item) => {
@@ -652,7 +652,7 @@ export function renderBatchEvaluationDetail(report, lang = DEFAULT_LANG) {
652
652
  export function renderReportDocumentDetail(report, lang = DEFAULT_LANG) {
653
653
  if (!report)
654
654
  return renderRunDetail(null, lang);
655
- return report.reportKind === 'batch-evaluation'
655
+ return report.kind === 'batch-evaluation'
656
656
  ? renderBatchEvaluationDetail(report, lang)
657
657
  : renderRunDetail(report, lang);
658
658
  }
@@ -762,7 +762,7 @@ export function createReportServer({ port, host: hostOption, reportsDir = DEFAUL
762
762
  let evalReport = null;
763
763
  if (entry.eval) {
764
764
  const r = await reportStore.get(entry.eval.reportId);
765
- if (r && r.reportKind === 'evaluation')
765
+ if (r && r.kind === 'evaluation')
766
766
  evalReport = r;
767
767
  }
768
768
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });