oh-my-knowledge 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +5 -3
  2. package/README.zh.md +5 -3
  3. package/dist/assets/agent-skills/omk/SKILL.md +4 -2
  4. package/dist/assets/agent-skills/omk/references/commands.md +121 -2
  5. package/dist/authoring/evolver.js +1 -1
  6. package/dist/cli/commands/doctor.js +21 -5
  7. package/dist/cli/commands/eval/index.js +7 -6
  8. package/dist/cli/commands/evolve.d.ts +11 -0
  9. package/dist/cli/commands/evolve.js +105 -5
  10. package/dist/cli/commands/list.d.ts +25 -0
  11. package/dist/cli/commands/list.js +118 -0
  12. package/dist/cli/commands/promote.d.ts +24 -0
  13. package/dist/cli/commands/promote.js +158 -0
  14. package/dist/cli/commands/rollback.d.ts +20 -0
  15. package/dist/cli/commands/rollback.js +95 -0
  16. package/dist/cli/commands/sample.d.ts +1 -0
  17. package/dist/cli/commands/sample.js +10 -4
  18. package/dist/cli/lib/cell-format.d.ts +17 -0
  19. package/dist/cli/lib/cell-format.js +19 -0
  20. package/dist/cli/lib/cmd-flags.d.ts +1 -0
  21. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  22. package/dist/cli/lib/i18n-dict/common.js +8 -0
  23. package/dist/cli/lib/i18n-dict/list.d.ts +3 -0
  24. package/dist/cli/lib/i18n-dict/list.js +36 -0
  25. package/dist/cli/lib/i18n-dict/promote.d.ts +3 -0
  26. package/dist/cli/lib/i18n-dict/promote.js +54 -0
  27. package/dist/cli/lib/i18n-dict/rollback.d.ts +3 -0
  28. package/dist/cli/lib/i18n-dict/rollback.js +22 -0
  29. package/dist/cli/lib/i18n-dict.d.ts +4 -1
  30. package/dist/cli/lib/i18n-dict.js +6 -0
  31. package/dist/cli/lib/progress.d.ts +3 -0
  32. package/dist/cli/lib/progress.js +22 -0
  33. package/dist/cli/lib/run-tally.js +1 -1
  34. package/dist/cli/lib/shared.js +1 -1
  35. package/dist/cli/lib/source-probe.d.ts +15 -0
  36. package/dist/cli/lib/source-probe.js +129 -0
  37. package/dist/doctor/endpoint-rule.d.ts +60 -0
  38. package/dist/doctor/endpoint-rule.js +381 -0
  39. package/dist/doctor/health/load-custom-dimensions.d.ts +6 -0
  40. package/dist/doctor/health/load-custom-dimensions.js +43 -3
  41. package/dist/doctor/index.js +9 -2
  42. package/dist/eval-core/evaluation-reporting.d.ts +1 -0
  43. package/dist/eval-core/evaluation-reporting.js +6 -4
  44. package/dist/eval-workflows/batch-evaluation-workflow.js +3 -2
  45. package/dist/eval-workflows/run-evaluation.js +2 -2
  46. package/dist/executors/codex-cli.js +5 -1
  47. package/dist/inputs/skill-loader.js +6 -3
  48. package/dist/managed/index.d.ts +2 -0
  49. package/dist/managed/index.js +2 -0
  50. package/dist/managed/list-view.d.ts +58 -0
  51. package/dist/managed/list-view.js +70 -0
  52. package/dist/managed/promote-gate.d.ts +40 -0
  53. package/dist/managed/promote-gate.js +37 -0
  54. package/dist/managed/store.d.ts +25 -2
  55. package/dist/managed/store.js +137 -11
  56. package/dist/observability/experience.d.ts +2 -0
  57. package/dist/observability/experience.js +31 -2
  58. package/dist/observability/inbox.js +31 -5
  59. package/dist/observability/review-state.js +22 -11
  60. package/dist/observability/soft-standards/llm-extractor.js +3 -3
  61. package/dist/observability/soft-standards/skill-standards-store.d.ts +1 -0
  62. package/dist/observability/soft-standards/skill-standards-store.js +77 -14
  63. package/dist/observability/soft-standards/types.d.ts +2 -2
  64. package/dist/renderer/doctor-detail-renderer.d.ts +9 -0
  65. package/dist/renderer/doctor-detail-renderer.js +114 -0
  66. package/dist/renderer/html-renderer.d.ts +3 -2
  67. package/dist/renderer/html-renderer.js +102 -102
  68. package/dist/renderer/icons.d.ts +29 -0
  69. package/dist/renderer/icons.js +66 -0
  70. package/dist/renderer/layout.js +81 -68
  71. package/dist/renderer/observation-inbox/styles.d.ts +1 -1
  72. package/dist/renderer/observation-inbox/styles.js +53 -53
  73. package/dist/renderer/report-shell.d.ts +77 -0
  74. package/dist/renderer/report-shell.js +223 -0
  75. package/dist/renderer/skill-detail-renderer.js +147 -352
  76. package/dist/renderer/skill-health-renderer.js +50 -73
  77. package/dist/renderer/skill-list-renderer.js +358 -306
  78. package/dist/renderer/summary.js +272 -162
  79. package/dist/renderer/test-view.d.ts +4 -3
  80. package/dist/renderer/test-view.js +386 -135
  81. package/dist/server/report-server.js +142 -28
  82. package/dist/server/report-store.d.ts +1 -1
  83. package/dist/server/report-store.js +16 -15
  84. package/dist/server/skill-index.js +5 -4
  85. package/dist/types/doctor.d.ts +24 -2
  86. package/dist/types/doctor.js +1 -1
  87. package/dist/types/managed.d.ts +17 -3
  88. package/dist/types/observability.d.ts +6 -6
  89. package/dist/types/report.d.ts +9 -4
  90. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { LANG_FLAG, bilingual } from '../oclif/i18n.js';
3
+ import { BaseCommand } from '../oclif/base-command.js';
4
+ import { tCli } from '../lib/i18n.js';
5
+ import { probeSourceState } from '../lib/source-probe.js';
6
+ import { sanitizeCell } from '../lib/cell-format.js';
7
+ import { buildManagedListRows, globalManagedDir, loadAllManagedRecords, managedDir, resolveManagedDir, } from '../../managed/index.js';
8
+ // 源探测(含 DoS / 软硬链 / 投毒守卫)已抽到 ../lib/source-probe.js,promote 复用同一套守卫与 drift 判定。
9
+ // 此处 re-export 保持既有 import 入口不破(test/cli/list-probe.test.ts 仍从本文件取 probeSourceState)。
10
+ export { probeSourceState };
11
+ /** CJK 全角字符按 2 列计宽,使含中文表头的列也能对齐。 */
12
+ export function dispWidth(s) {
13
+ let w = 0;
14
+ for (const ch of s)
15
+ w += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1;
16
+ return w;
17
+ }
18
+ function pad(s, width) {
19
+ return s + ' '.repeat(Math.max(0, width - dispWidth(s)));
20
+ }
21
+ /** 按**显示宽度**截断(不是 code unit):逐码点累加 dispWidth,绝不切断 surrogate 对、CJK 也不溢出列。 */
22
+ export function truncate(s, max) {
23
+ if (dispWidth(s) <= max)
24
+ return s;
25
+ let w = 0;
26
+ let out = '';
27
+ for (const ch of s) {
28
+ const cw = dispWidth(ch);
29
+ if (w + cw > max - 1)
30
+ break; // 留 1 列给省略号
31
+ out += ch;
32
+ w += cw;
33
+ }
34
+ return `${out}…`;
35
+ }
36
+ // sanitizeCell 已下沉到 ../lib/cell-format.js(promote 文本输出复用同一套不可信字符洗白)。
37
+ // 此处 re-export 保持既有 import 入口不破(test/cli/list-probe.test.ts 仍从本文件取 sanitizeCell)。
38
+ export { sanitizeCell };
39
+ export function renderTable(rows, lang) {
40
+ const headers = [
41
+ tCli('cli.list.col_name', lang),
42
+ tCli('cli.list.col_kind', lang),
43
+ tCli('cli.list.col_state', lang),
44
+ tCli('cli.list.col_verdict', lang),
45
+ tCli('cli.list.col_evidence', lang),
46
+ tCli('cli.list.col_source', lang),
47
+ ];
48
+ const cells = rows.map((r) => [
49
+ truncate(sanitizeCell(r.name), 40), // name 与 source 同为用户可控、可超长 → 同样按显示宽度截断,防撑爆表宽
50
+ r.kind, // ArtifactKind 枚举(validator 已收窄),无需洗
51
+ // 不可达 → 标「?」(drift 未核),绝不冒充 stale;reachable 且漂移才 stale ⚠️;已人工接受标 promoted ✓。
52
+ !r.reachable ? `${r.state} ?` : r.state === 'stale' ? 'stale ⚠️' : r.state === 'promoted' ? 'promoted ✓' : r.state,
53
+ r.latestVerdict ? sanitizeCell(r.latestVerdict) : '—',
54
+ `${r.currentEvidenceCount}/${r.totalEvidenceCount}`,
55
+ truncate(sanitizeCell(r.sourceLabel), 48),
56
+ ]);
57
+ const widths = headers.map((h, i) => Math.max(dispWidth(h), ...cells.map((c) => dispWidth(c[i]))));
58
+ const line = (c) => c.map((v, i) => pad(v, widths[i])).join(' ').trimEnd();
59
+ return [line(headers), ...cells.map(line)].join('\n');
60
+ }
61
+ export default class List extends BaseCommand {
62
+ static description = bilingual({
63
+ zh: '列出受管 skill 及其证据状态:生命周期(installed / measurable / promoted / stale)、最新 verdict、证据数、源。',
64
+ en: 'List managed skills with evidence status: lifecycle (installed / measurable / promoted / stale), latest verdict, evidence count, source.',
65
+ });
66
+ static examples = [
67
+ { description: bilingual({ zh: '列出当前项目的受管 skill', en: 'List managed skills in the current project' }), command: '<%= config.bin %> list' },
68
+ { description: bilingual({ zh: '列出全局受管 skill', en: 'List globally managed skills' }), command: '<%= config.bin %> list --global' },
69
+ { description: bilingual({ zh: '机器可读 JSON 输出', en: 'Machine-readable JSON output' }), command: '<%= config.bin %> list --json' },
70
+ ];
71
+ static flags = {
72
+ lang: LANG_FLAG,
73
+ global: Flags.boolean({
74
+ description: bilingual({ zh: '看全局受管目录(~/.oh-my-knowledge/managed)而非项目 .omk/managed', en: 'Show the global managed dir (~/.oh-my-knowledge/managed) instead of project .omk/managed' }),
75
+ }),
76
+ json: Flags.boolean({
77
+ description: bilingual({ zh: '输出 JSON(含完整可比性 marker),供脚本消费', en: 'Output JSON (with full comparability markers) for scripts' }),
78
+ }),
79
+ };
80
+ async run() {
81
+ const { flags } = await this.parse(List);
82
+ const lang = this.lang;
83
+ await this.runWithCliExit(async () => {
84
+ const dir = flags.global ? globalManagedDir() : resolveManagedDir(managedDir());
85
+ const records = loadAllManagedRecords(dir);
86
+ const rows = buildManagedListRows(records, probeSourceState);
87
+ if (flags.json) {
88
+ // 版本化信封 —— 与 eval / doctor / diagnosis 等机读出口一致(都带 schemaVersion),让未来字段增删 /
89
+ // 改名是可检测的版本 bump,而非脚本静默 break。rows 的逐行形态见 ManagedListRow。
90
+ this.log(JSON.stringify({ schemaVersion: 1, rows }, null, 2));
91
+ return;
92
+ }
93
+ if (rows.length === 0) {
94
+ process.stderr.write(tCli('cli.list.empty', lang));
95
+ process.stderr.write(tCli('cli.list.empty_hint', lang));
96
+ return;
97
+ }
98
+ const scope = dir === globalManagedDir()
99
+ ? (lang === 'zh' ? '全局' : 'global')
100
+ : (lang === 'zh' ? '项目' : 'project');
101
+ process.stderr.write(tCli('cli.list.header', lang, { scope, count: rows.length }));
102
+ this.log(renderTable(rows, lang));
103
+ // 注脚块前留一行空白(无论先触发哪条 note);空行挂在块上而非 drift 分支,避免只 unreachable 时丢分隔。
104
+ const hasDrift = rows.some((r) => r.drifted);
105
+ const hasUnreachable = rows.some((r) => !r.reachable);
106
+ const hasPromoted = rows.some((r) => r.state === 'promoted');
107
+ if (hasDrift || hasUnreachable || hasPromoted)
108
+ process.stderr.write('\n');
109
+ if (hasPromoted)
110
+ process.stderr.write(tCli('cli.list.promoted_note', lang));
111
+ if (hasDrift)
112
+ process.stderr.write(tCli('cli.list.drift_note', lang));
113
+ if (hasUnreachable)
114
+ process.stderr.write(tCli('cli.list.unreachable_note', lang));
115
+ process.stderr.write(tCli('cli.list.legend', lang));
116
+ });
117
+ }
118
+ }
@@ -0,0 +1,24 @@
1
+ import { BaseCommand } from '../oclif/base-command.js';
2
+ export default class Promote extends BaseCommand {
3
+ static description: string;
4
+ static examples: {
5
+ description: string;
6
+ command: string;
7
+ }[];
8
+ static args: {
9
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
10
+ };
11
+ static flags: {
12
+ lang: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ kind: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'accept-cautious': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ reason: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ actor: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ };
21
+ run(): Promise<void>;
22
+ private emitBlocked;
23
+ private emitPromoted;
24
+ }
@@ -0,0 +1,158 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { LANG_FLAG, bilingual } from '../oclif/i18n.js';
4
+ import { BaseCommand } from '../oclif/base-command.js';
5
+ import { tCli } from '../lib/i18n.js';
6
+ import { CliExit } from '../lib/cli-exit.js';
7
+ import { probeSourceState } from '../lib/source-probe.js';
8
+ import { sanitizeCell } from '../lib/cell-format.js';
9
+ import { getJudgePromptHash } from '../../grading/judge.js';
10
+ import { appendManagedDecision, evaluatePromoteGate, globalManagedDir, isCurrentlyPromoted, loadManagedRecord, managedDir, managedRecordId, resolveManagedDir, } from '../../managed/index.js';
11
+ const SUPPORTED_KINDS = ['skill'];
12
+ /** 决定的 actor:--actor > git config user.name > $USER / $LOGNAME > unknown。git 参数无用户输入,安全。 */
13
+ function resolveActor(flagActor) {
14
+ if (flagActor && flagActor.trim())
15
+ return flagActor.trim();
16
+ try {
17
+ const name = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
18
+ if (name)
19
+ return name;
20
+ }
21
+ catch { /* git 缺失 / 无配置 → 回退环境 */ }
22
+ return process.env.USER || process.env.LOGNAME || 'unknown';
23
+ }
24
+ /** 洗 detail 里来自受管 JSON 的不可信值(verdict / judgePromptHash),文本输出防 ANSI/OSC 注入。 */
25
+ function safeDetail(detail) {
26
+ if (!detail)
27
+ return undefined;
28
+ return Object.fromEntries(Object.entries(detail).map(([k, v]) => [k, sanitizeCell(v)]));
29
+ }
30
+ /** blockKind → i18n key(细节 detail 作为 tCli params,文本路径经 safeDetail 洗白)。 */
31
+ const BLOCK_KEY = {
32
+ drifted: 'cli.promote.drifted',
33
+ no_current_evidence: 'cli.promote.no_current_evidence',
34
+ incomparable: 'cli.promote.incomparable',
35
+ verdict_blocked: 'cli.promote.verdict_blocked',
36
+ };
37
+ export default class Promote extends BaseCommand {
38
+ static description = bilingual({
39
+ zh: '把受管 skill 的当前版本按证据门禁「接受」为 promoted:默认仅放行 verdict=PROGRESS,在记录里追加一条带证据指针的人工决定。',
40
+ en: 'Accept a managed skill\'s current version as promoted, gated on evidence: by default only verdict=PROGRESS passes; appends a human decision with an evidence pointer to the record.',
41
+ });
42
+ static examples = [
43
+ { description: bilingual({ zh: 'promote 一个证据达标的 skill', en: 'Promote a skill whose evidence passes the gate' }), command: '<%= config.bin %> promote review' },
44
+ { description: bilingual({ zh: '接受 CAUTIOUS 结果(显式放宽门禁)', en: 'Accept a CAUTIOUS result (explicitly loosen the gate)' }), command: '<%= config.bin %> promote review --accept-cautious' },
45
+ { description: bilingual({ zh: '越门 promote 并记录理由(人工 override)', en: 'Override the gate and record a reason (human override)' }), command: '<%= config.bin %> promote review --force --reason "已人工复核"' },
46
+ ];
47
+ static args = {
48
+ name: Args.string({ description: bilingual({ zh: '受管 skill 名(omk list 里的 NAME)', en: 'Managed skill name (the NAME shown by omk list)' }), required: true }),
49
+ };
50
+ static flags = {
51
+ lang: LANG_FLAG,
52
+ kind: Flags.string({ description: bilingual({ zh: 'artifact 类型(当前仅 skill)', en: 'artifact kind (only skill today)' }), default: 'skill' }),
53
+ 'accept-cautious': Flags.boolean({ description: bilingual({ zh: '把 CAUTIOUS 也算可接受(默认仅 PROGRESS)', en: 'also accept CAUTIOUS (default PROGRESS only)' }) }),
54
+ force: Flags.boolean({ description: bilingual({ zh: '越过可越门拦截强制 promote,记为人工 override 决定(无当前证据或源 hash 已变时仍拒)', en: 'override forceable gate blocks and force-promote, recorded as a human override (still refused with no current evidence or changed source hash)' }) }),
55
+ reason: Flags.string({ description: bilingual({ zh: 'promote / 越门的理由(写入决定)', en: 'reason for the promotion / override (stored on the decision)' }) }),
56
+ actor: Flags.string({ description: bilingual({ zh: '决定的 actor(默认取 git config user.name)', en: 'decision actor (defaults to git config user.name)' }) }),
57
+ global: Flags.boolean({ description: bilingual({ zh: '操作全局受管目录而非项目 .omk/managed', en: 'operate on the global managed dir instead of project .omk/managed' }) }),
58
+ json: Flags.boolean({ description: bilingual({ zh: '输出 JSON(版本化信封)供脚本消费', en: 'output JSON (versioned envelope) for scripts' }) }),
59
+ };
60
+ async run() {
61
+ const { args, flags } = await this.parse(Promote);
62
+ const lang = this.lang;
63
+ await this.runWithCliExit(async () => {
64
+ const name = args.name;
65
+ const kind = flags.kind;
66
+ if (!SUPPORTED_KINDS.includes(kind)) {
67
+ process.stderr.write(tCli('cli.promote.kind_unsupported', lang, { kind: sanitizeCell(String(kind)) }) + '\n');
68
+ throw new CliExit(1);
69
+ }
70
+ const dir = flags.global ? globalManagedDir() : resolveManagedDir(managedDir());
71
+ const record = loadManagedRecord(dir, managedRecordId(kind, name));
72
+ if (!record) {
73
+ process.stderr.write(tCli('cli.promote.not_managed', lang, { name: sanitizeCell(name), kind }) + '\n');
74
+ throw new CliExit(1);
75
+ }
76
+ const probe = probeSourceState(record);
77
+ const currentContentHash = probe.reachable ? probe.hash : undefined;
78
+ const sourceHashMismatch = probe.reachable && currentContentHash !== record.contentHash;
79
+ // 幂等:当前内容已 promote 过 → 无操作成功退出,不堆冗余事件。与 list 的不可达分支同口径:
80
+ // 源不可达不代表内容已变;只有源可达且 hash 已变时,才不把旧 promote 当作当前 no-op。
81
+ const alreadyPromoted = !sourceHashMismatch && isCurrentlyPromoted(record);
82
+ if (alreadyPromoted) {
83
+ if (flags.json)
84
+ this.log(JSON.stringify({ schemaVersion: 1, alreadyPromoted: { name, contentHash: record.contentHash } }, null, 2));
85
+ else
86
+ process.stderr.write(tCli('cli.promote.already_promoted', lang, { name: sanitizeCell(name) }) + '\n');
87
+ return;
88
+ }
89
+ const currentJudgeHashes = new Set([getJudgePromptHash(true), getJudgePromptHash(false)]);
90
+ const gate = evaluatePromoteGate({ record, currentContentHash, acceptCautious: flags['accept-cautious'], currentJudgeHashes });
91
+ // 拦截:无当前证据 force 也越不过;源可达但 hash 已变也不可越门,因为 decision 仍锚在
92
+ // record.contentHash,越过去后 list 仍会按当前源 hash 判 stale。源不可达则可作为人工未核越门。
93
+ const forceable = gate.evidence !== undefined && !sourceHashMismatch;
94
+ const reason = flags.reason?.trim();
95
+ if (!gate.ok && flags.force && forceable && !reason) {
96
+ if (flags.json)
97
+ this.log(JSON.stringify({ schemaVersion: 1, blocked: { name, reasons: [{ blockKind: 'force_reason_required' }] } }, null, 2));
98
+ else
99
+ process.stderr.write(tCli('cli.promote.force_reason_required', lang) + '\n');
100
+ throw new CliExit(1);
101
+ }
102
+ if (!gate.ok && !(flags.force && forceable)) {
103
+ this.emitBlocked(name, gate, lang, flags.json, forceable);
104
+ throw new CliExit(1);
105
+ }
106
+ // 通过 或 force 越门:写决定。
107
+ const overridden = !gate.ok; // 走到这里且未提前 return,说明是 force 越门
108
+ const evidence = gate.evidence; // forceable 已保证非空(无证据分支在上面已拦)
109
+ const decision = {
110
+ decisionKind: 'promote',
111
+ actor: resolveActor(flags.actor),
112
+ decidedAt: new Date().toISOString(),
113
+ contentHash: record.contentHash,
114
+ ...(evidence.reportId ? { reportId: evidence.reportId } : {}),
115
+ ...(reason ? { reason } : {}),
116
+ ...(overridden ? { override: { verdict: evidence.verdict ?? 'UNKNOWN', overriddenBlocks: gate.blocked.map((b) => b.blockKind) } } : {}),
117
+ };
118
+ appendManagedDecision(dir, record.id, decision);
119
+ // 非拦截级提醒(如 judgePromptHash 缺失)始终提示。当前 warnings 仅 incomparable_unverified 一类。
120
+ if (gate.warnings.length > 0)
121
+ process.stderr.write(tCli('cli.promote.incomparable_unverified', lang) + '\n');
122
+ this.emitPromoted(name, record, evidence.verdict, evidence.reportId, overridden, lang, flags.json);
123
+ });
124
+ }
125
+ emitBlocked(name, gate, lang, json, forceable) {
126
+ if (json) {
127
+ this.log(JSON.stringify({ schemaVersion: 1, blocked: { name, reasons: gate.blocked.map((b) => ({ blockKind: b.blockKind, ...(b.detail ? { detail: b.detail } : {}) })) } }, null, 2));
128
+ return;
129
+ }
130
+ process.stderr.write(tCli('cli.promote.blocked_header', lang, { name: sanitizeCell(name) }) + '\n');
131
+ // detail 里的 verdict / judgePromptHash 来自不可信受管 JSON,文本路径先洗(与 list 同口径,防 ANSI/OSC 注入)。
132
+ for (const b of gate.blocked)
133
+ process.stderr.write(tCli(BLOCK_KEY[b.blockKind], lang, safeDetail(b.detail)) + '\n');
134
+ if (gate.warnings.length > 0)
135
+ process.stderr.write(tCli('cli.promote.incomparable_unverified', lang) + '\n');
136
+ // 有可锚定证据(非「无证据」终态)才提示 --force 可越门。
137
+ if (forceable)
138
+ process.stderr.write(tCli('cli.promote.force_hint', lang) + '\n');
139
+ }
140
+ emitPromoted(name, record, verdict, reportId, overridden, lang, json) {
141
+ const hash = record.contentHash;
142
+ if (json) {
143
+ this.log(JSON.stringify({
144
+ schemaVersion: 1,
145
+ promoted: { name, kind: record.kind, contentHash: hash, ...(reportId ? { reportId } : {}), verdict: verdict ?? null, override: overridden },
146
+ }, null, 2));
147
+ return;
148
+ }
149
+ // hash / verdict / reportId 来自不可信受管 JSON,文本路径先洗(--json 路径上面已 return、保留原值)。
150
+ const short = sanitizeCell(hash.slice(0, 12));
151
+ const safeVerdict = sanitizeCell(verdict ?? 'UNKNOWN');
152
+ const safeName = sanitizeCell(name);
153
+ if (overridden)
154
+ this.log(tCli('cli.promote.forced_override', lang, { name: safeName, hash: short, verdict: safeVerdict }));
155
+ else
156
+ this.log(tCli('cli.promote.promoted', lang, { name: safeName, hash: short, verdict: safeVerdict, reportId: sanitizeCell(reportId ?? '—') }));
157
+ }
158
+ }
@@ -0,0 +1,20 @@
1
+ import { BaseCommand } from '../oclif/base-command.js';
2
+ export default class Rollback extends BaseCommand {
3
+ static description: string;
4
+ static examples: {
5
+ description: string;
6
+ command: string;
7
+ }[];
8
+ static args: {
9
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
10
+ };
11
+ static flags: {
12
+ lang: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ kind: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
+ reason: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ actor: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ };
19
+ run(): Promise<void>;
20
+ }
@@ -0,0 +1,95 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { LANG_FLAG, bilingual } from '../oclif/i18n.js';
4
+ import { BaseCommand } from '../oclif/base-command.js';
5
+ import { tCli } from '../lib/i18n.js';
6
+ import { CliExit } from '../lib/cli-exit.js';
7
+ import { sanitizeCell } from '../lib/cell-format.js';
8
+ import { appendManagedDecision, globalManagedDir, isCurrentlyPromoted, loadManagedRecord, managedDir, managedRecordId, resolveManagedDir, } from '../../managed/index.js';
9
+ const SUPPORTED_KINDS = ['skill'];
10
+ /** 决定的 actor:--actor > git config user.name > $USER / $LOGNAME > unknown。git 参数无用户输入,安全。 */
11
+ function resolveActor(flagActor) {
12
+ if (flagActor && flagActor.trim())
13
+ return flagActor.trim();
14
+ try {
15
+ const name = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
16
+ if (name)
17
+ return name;
18
+ }
19
+ catch { /* git 缺失 / 无配置 → 回退环境 */ }
20
+ return process.env.USER || process.env.LOGNAME || 'unknown';
21
+ }
22
+ export default class Rollback extends BaseCommand {
23
+ static description = bilingual({
24
+ zh: '回退受管 skill 当前版本的 promoted 接受:撤销最近一次 promote,在记录里追加一条 rollback 决定(源未漂移则状态回到 measurable,源已漂移则仍 stale)。',
25
+ en: 'Roll back a managed skill\'s current promoted acceptance: revoke the latest promote by appending a rollback decision (state returns to measurable if the source is unchanged, or stays stale if it has drifted).',
26
+ });
27
+ static examples = [
28
+ { description: bilingual({ zh: '回退一个已 promoted 的 skill', en: 'Roll back a promoted skill' }), command: '<%= config.bin %> rollback review' },
29
+ { description: bilingual({ zh: '回退并记录理由', en: 'Roll back and record a reason' }), command: '<%= config.bin %> rollback review --reason "线上发现回归"' },
30
+ ];
31
+ static args = {
32
+ name: Args.string({ description: bilingual({ zh: '受管 skill 名(omk list 里的 NAME)', en: 'Managed skill name (the NAME shown by omk list)' }), required: true }),
33
+ };
34
+ static flags = {
35
+ lang: LANG_FLAG,
36
+ kind: Flags.string({ description: bilingual({ zh: 'artifact 类型(当前仅 skill)', en: 'artifact kind (only skill today)' }), default: 'skill' }),
37
+ reason: Flags.string({ description: bilingual({ zh: '回退的理由(写入决定)', en: 'reason for the rollback (stored on the decision)' }) }),
38
+ actor: Flags.string({ description: bilingual({ zh: '决定的 actor(默认取 git config user.name)', en: 'decision actor (defaults to git config user.name)' }) }),
39
+ global: Flags.boolean({ description: bilingual({ zh: '操作全局受管目录而非项目 .omk/managed', en: 'operate on the global managed dir instead of project .omk/managed' }) }),
40
+ json: Flags.boolean({ description: bilingual({ zh: '输出 JSON(版本化信封)供脚本消费', en: 'output JSON (versioned envelope) for scripts' }) }),
41
+ };
42
+ async run() {
43
+ const { args, flags } = await this.parse(Rollback);
44
+ const lang = this.lang;
45
+ await this.runWithCliExit(async () => {
46
+ const name = args.name;
47
+ const kind = flags.kind;
48
+ if (!SUPPORTED_KINDS.includes(kind)) {
49
+ process.stderr.write(tCli('cli.rollback.kind_unsupported', lang, { kind: sanitizeCell(String(kind)) }) + '\n');
50
+ throw new CliExit(1);
51
+ }
52
+ const dir = flags.global ? globalManagedDir() : resolveManagedDir(managedDir());
53
+ const record = loadManagedRecord(dir, managedRecordId(kind, name));
54
+ if (!record) {
55
+ process.stderr.write(tCli('cli.rollback.not_managed', lang, { name: sanitizeCell(name), kind }) + '\n');
56
+ throw new CliExit(1);
57
+ }
58
+ // rollback 是内容锚定的纯决定撤销:只看 record.contentHash 上的 promote/rollback 事件流,不探源、不设门禁
59
+ // (降级永远安全)。当前未 promoted → 无可回退;若曾对当前内容 promote 过(即已回退)则幂等成功,否则报错。
60
+ if (!isCurrentlyPromoted(record)) {
61
+ const hadPromote = record.decisions.some((d) => d.decisionKind === 'promote' && d.contentHash === record.contentHash);
62
+ if (hadPromote) {
63
+ if (flags.json)
64
+ this.log(JSON.stringify({ schemaVersion: 1, alreadyRolledBack: { name, contentHash: record.contentHash } }, null, 2));
65
+ else
66
+ process.stderr.write(tCli('cli.rollback.already_rolled_back', lang, { name: sanitizeCell(name) }) + '\n');
67
+ return;
68
+ }
69
+ if (flags.json)
70
+ this.log(JSON.stringify({ schemaVersion: 1, notPromoted: { name } }, null, 2));
71
+ else
72
+ process.stderr.write(tCli('cli.rollback.not_promoted', lang, { name: sanitizeCell(name) }) + '\n');
73
+ throw new CliExit(1);
74
+ }
75
+ const reason = flags.reason?.trim();
76
+ const decision = {
77
+ decisionKind: 'rollback',
78
+ actor: resolveActor(flags.actor),
79
+ decidedAt: new Date().toISOString(),
80
+ contentHash: record.contentHash,
81
+ ...(reason ? { reason } : {}),
82
+ };
83
+ appendManagedDecision(dir, record.id, decision);
84
+ if (flags.json) {
85
+ this.log(JSON.stringify({
86
+ schemaVersion: 1,
87
+ rolledBack: { name, kind: record.kind, contentHash: record.contentHash, ...(reason ? { reason } : {}) },
88
+ }, null, 2));
89
+ return;
90
+ }
91
+ // hash 来自不可信受管 JSON,文本路径先洗(--json 路径上面已 return、保留原值)。
92
+ this.log(tCli('cli.rollback.rolled_back', lang, { name: sanitizeCell(name), hash: sanitizeCell(record.contentHash.slice(0, 12)) }));
93
+ });
94
+ }
95
+ }
@@ -26,6 +26,7 @@ export default class Sample extends BaseCommand {
26
26
  batch: import("@oclif/core/interfaces").BooleanFlag<boolean>;
27
27
  count: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
28
28
  model: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
29
+ executor: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
29
30
  'skill-dir': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
30
31
  focus: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
31
32
  'no-mock': import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -225,7 +225,7 @@ async function runSampleFix(args, flags, lang) {
225
225
  }
226
226
  process.stderr.write(lang === 'zh' ? `🔧 发现 ${sampleDesignCount} 条 sample_design 失败,开始修复...\n` : `🔧 Found ${sampleDesignCount} sample_design failure(s), fixing...\n`);
227
227
  const { createExecutor } = await import('../../executors/index.js');
228
- const exec = createExecutor('claude');
228
+ const exec = createExecutor(flags.executor ?? 'claude');
229
229
  const executorFn = async (opts) => {
230
230
  const result = await exec({
231
231
  model: opts.model,
@@ -298,7 +298,7 @@ async function runSampleFromTraces(flags, lang) {
298
298
  ? `🔭 发现 ${items.length} 个失败信号,正在生成回归用例草稿...\n`
299
299
  : `🔭 Found ${items.length} failure signal(s); generating regression-sample drafts...\n`);
300
300
  try {
301
- const { samples, costUSD } = await generateSamplesFromTraces({ items, count, model: flags.model });
301
+ const { samples, costUSD } = await generateSamplesFromTraces({ items, count, model: flags.model, executorName: flags.executor });
302
302
  const cost = costUSD > 0 ? ` $${costUSD.toFixed(4)}` : '';
303
303
  if (samples.length === 0) {
304
304
  // The model conservatively skipped every signal (noise / unreproducible). That's a
@@ -380,7 +380,7 @@ async function runSample(args, flags, lang) {
380
380
  }
381
381
  try {
382
382
  const skillContent = readFileSync(skillPath, 'utf-8');
383
- const { samples, costUSD } = await generateSamples({ skillContent, count, model, focus, noMock: flags['no-mock'] });
383
+ const { samples, costUSD } = await generateSamples({ skillContent, count, model, focus, noMock: flags['no-mock'], executorName: flags.executor });
384
384
  mkdirSync(dirname(samplesPath), { recursive: true });
385
385
  writeFileSync(samplesPath, JSON.stringify(samples, null, 2));
386
386
  const cost = costUSD > 0 ? ` $${costUSD.toFixed(4)}` : '';
@@ -444,7 +444,7 @@ async function runSample(args, flags, lang) {
444
444
  process.stderr.write(tCli('cli.gen.single_generating_auto', lang));
445
445
  }
446
446
  try {
447
- const { samples, costUSD } = await generateSamples({ skillContent, count, model, focus, noMock: flags['no-mock'] });
447
+ const { samples, costUSD } = await generateSamples({ skillContent, count, model, focus, noMock: flags['no-mock'], executorName: flags.executor });
448
448
  mkdirSync(dirname(outputPath), { recursive: true });
449
449
  writeFileSync(outputPath, JSON.stringify(samples, null, 2));
450
450
  const cost = costUSD > 0 ? ` $${costUSD.toFixed(4)}` : '';
@@ -528,6 +528,12 @@ export default class Sample extends BaseCommand {
528
528
  }),
529
529
  default: 'sonnet',
530
530
  }),
531
+ executor: Flags.string({
532
+ description: bilingual({
533
+ zh: '执行器名,默认 claude(同 omk eval / doctor / evolve)。指定 codex 等其它执行器时,记得连带传一个该执行器能识别的 --model。',
534
+ en: 'Executor name, default claude (same as omk eval / doctor / evolve). When using another executor like codex, also pass a --model it recognizes.',
535
+ }),
536
+ }),
531
537
  'skill-dir': Flags.string({
532
538
  description: bilingual({
533
539
  zh: 'skill 根目录,默认 skills。batch 模式扫此目录。',
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 终端展示前的不可信字符洗白——`omk list` 与 `omk promote` 共用。受管 JSON 可随仓库分发、用户可手改
3
+ * (store.ts validator 只收窄到「是字符串」,不卡内容),把它的字段(name / verdict / judgePromptHash /
4
+ * reportId / sourceLabel 等)塞进终端输出前必须先洗,否则 ANSI / OSC 转义、BiDi 重排、零宽分割会破坏排版
5
+ * 甚至伪造输出(清屏、改窗口标题、覆盖历史行误导审计)。一律映射到可见 U+FFFD;`--json` 路径保留原值
6
+ * (JSON.stringify 自带控制符转义,脚本消费要的是原始值)。
7
+ *
8
+ * 用 Unicode **属性类**而非手列码点 —— 手列清单天然有缺口(BiDi、U+2028 / 2029、Tags 块都曾漏一轮补一轮),
9
+ * 属性类一次覆盖整类、新码点自动纳入:
10
+ * - `\p{Cc}` 控制符(C0 / C1 / DEL,含 ESC / 换行 / 回车 / TAB)→ 杀 ANSI / OSC 转义与终端控制;
11
+ * - `\p{Cf}` 格式符(BiDi 重排 / 隔离、零宽、joiners、BOM、Tags、interlinear)→ 防 Trojan-Source 视觉伪造与零宽隐藏 / 分割;
12
+ * - `\p{Zl}` / `\p{Zp}` 行 / 段分隔(U+2028 / 2029)→ LF 的 Unicode 孪生,防换行伪造;
13
+ * - `\p{Mn}` / `\p{Me}` 非间距 / 封闭组合附加符 → 变可见,避免零前进宽度令列宽与终端错位
14
+ * (间距组合符 `\p{Mc}` 合法占 1 列,保留);
15
+ * - Hangul filler(U+115F / U+1160 / U+3164,属 Lo 不在上述任何类)→ 零宽显示诡计,补列。
16
+ */
17
+ export declare function sanitizeCell(s: string): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 终端展示前的不可信字符洗白——`omk list` 与 `omk promote` 共用。受管 JSON 可随仓库分发、用户可手改
3
+ * (store.ts validator 只收窄到「是字符串」,不卡内容),把它的字段(name / verdict / judgePromptHash /
4
+ * reportId / sourceLabel 等)塞进终端输出前必须先洗,否则 ANSI / OSC 转义、BiDi 重排、零宽分割会破坏排版
5
+ * 甚至伪造输出(清屏、改窗口标题、覆盖历史行误导审计)。一律映射到可见 U+FFFD;`--json` 路径保留原值
6
+ * (JSON.stringify 自带控制符转义,脚本消费要的是原始值)。
7
+ *
8
+ * 用 Unicode **属性类**而非手列码点 —— 手列清单天然有缺口(BiDi、U+2028 / 2029、Tags 块都曾漏一轮补一轮),
9
+ * 属性类一次覆盖整类、新码点自动纳入:
10
+ * - `\p{Cc}` 控制符(C0 / C1 / DEL,含 ESC / 换行 / 回车 / TAB)→ 杀 ANSI / OSC 转义与终端控制;
11
+ * - `\p{Cf}` 格式符(BiDi 重排 / 隔离、零宽、joiners、BOM、Tags、interlinear)→ 防 Trojan-Source 视觉伪造与零宽隐藏 / 分割;
12
+ * - `\p{Zl}` / `\p{Zp}` 行 / 段分隔(U+2028 / 2029)→ LF 的 Unicode 孪生,防换行伪造;
13
+ * - `\p{Mn}` / `\p{Me}` 非间距 / 封闭组合附加符 → 变可见,避免零前进宽度令列宽与终端错位
14
+ * (间距组合符 `\p{Mc}` 合法占 1 列,保留);
15
+ * - Hangul filler(U+115F / U+1160 / U+3164,属 Lo 不在上述任何类)→ 零宽显示诡计,补列。
16
+ */
17
+ export function sanitizeCell(s) {
18
+ return s.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Mn}\p{Me}ᅟᅠㅤ]/gu, '�');
19
+ }
@@ -149,6 +149,7 @@ export interface SampleFlags {
149
149
  batch: boolean;
150
150
  count?: string;
151
151
  model: string;
152
+ executor?: string;
152
153
  'skill-dir': string;
153
154
  focus?: string;
154
155
  'no-mock': boolean;
@@ -1,3 +1,3 @@
1
1
  import type { CliMessage } from './types.js';
2
- export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected';
2
+ export type CommonMessageKey = 'cli.common.unknown_domain' | 'cli.common.error_prefix' | 'cli.common.skill_dir_not_found' | 'cli.common.skill_file_not_found' | 'cli.common.skill_dir_no_skill_md' | 'cli.common.report_not_found' | 'cli.common.no_judge_model' | 'cli.common.judge_models_single_only' | 'cli.common.warn_load_samples_failed' | 'cli.update.new_version_available' | 'cli.update.box_title' | 'cli.update.box_version_line' | 'cli.update.box_upgrade_line' | 'cli.update.box_silence_line' | 'cli.observe.view_hint' | 'cli.studio.started' | 'cli.studio.stop_hint' | 'cli.studio.open_failed' | 'cli.doctor.no_skill_found' | 'cli.doctor.samples_detected' | 'cli.doctor.progress_skill_start' | 'cli.doctor.progress_skill_done';
3
3
  export declare const commonDict: Record<CommonMessageKey, CliMessage>;
@@ -79,4 +79,12 @@ export const commonDict = {
79
79
  zh: '✓ 使用评测用例文件:{path}',
80
80
  en: '✓ Using eval samples file: {path}',
81
81
  },
82
+ 'cli.doctor.progress_skill_start': {
83
+ zh: '{prefix}{skill} ⏳ 体检中...\n',
84
+ en: '{prefix}{skill} ⏳ checking...\n',
85
+ },
86
+ 'cli.doctor.progress_skill_done': {
87
+ zh: '{prefix}{skill} {result}({ms}ms)\n',
88
+ en: '{prefix}{skill} {result} ({ms}ms)\n',
89
+ },
82
90
  };
@@ -0,0 +1,3 @@
1
+ import type { CliMessage } from './types.js';
2
+ export type ListMessageKey = 'cli.list.header' | 'cli.list.empty' | 'cli.list.empty_hint' | 'cli.list.col_name' | 'cli.list.col_kind' | 'cli.list.col_state' | 'cli.list.col_verdict' | 'cli.list.col_evidence' | 'cli.list.col_source' | 'cli.list.drift_note' | 'cli.list.unreachable_note' | 'cli.list.promoted_note' | 'cli.list.legend';
3
+ export declare const listDict: Record<ListMessageKey, CliMessage>;
@@ -0,0 +1,36 @@
1
+ export const listDict = {
2
+ 'cli.list.header': {
3
+ zh: '受管 skill({scope},{count} 条)\n',
4
+ en: 'Managed skills ({scope}, {count})\n',
5
+ },
6
+ 'cli.list.empty': {
7
+ zh: '没有受管记录。\n',
8
+ en: 'No managed records.\n',
9
+ },
10
+ 'cli.list.empty_hint': {
11
+ zh: '用 omk install <skill> 登记并分发一个 skill 后,它会出现在这里。\n',
12
+ en: 'Run omk install <skill> to register and distribute a skill; it will show up here.\n',
13
+ },
14
+ 'cli.list.col_name': { zh: '名称', en: 'NAME' },
15
+ 'cli.list.col_kind': { zh: '类型', en: 'KIND' },
16
+ 'cli.list.col_state': { zh: '状态', en: 'STATE' },
17
+ 'cli.list.col_verdict': { zh: 'VERDICT', en: 'VERDICT' },
18
+ 'cli.list.col_evidence': { zh: '证据', en: 'EVIDENCE' },
19
+ 'cli.list.col_source': { zh: '源', en: 'SOURCE' },
20
+ 'cli.list.drift_note': {
21
+ zh: '⚠️ = 源内容已漂移、脱离证据(stale);重跑 omk eval 重新取证。\n',
22
+ en: '⚠️ = source content drifted off its evidence (stale); re-run omk eval to re-measure.\n',
23
+ },
24
+ 'cli.list.unreachable_note': {
25
+ zh: '? = 源此处不可达 / 拒读,drift 未核(非 stale);本地 git 源请在原仓库根目录跑 omk list。\n',
26
+ en: '? = source unreachable / refused here, drift unchecked (not stale); for a local git source run omk list from the original repo root.\n',
27
+ },
28
+ 'cli.list.promoted_note': {
29
+ zh: '✓ = 当前版本已按证据人工接受为 promoted(omk promote)。\n',
30
+ en: '✓ = current version accepted as promoted on evidence (omk promote).\n',
31
+ },
32
+ 'cli.list.legend': {
33
+ zh: '证据列 = 当前有效 / 全部(历史含旧内容证据,供回滚)。\n',
34
+ en: 'EVIDENCE column = current / total (history keeps old-content evidence for rollback).\n',
35
+ },
36
+ };
@@ -0,0 +1,3 @@
1
+ import type { CliMessage } from './types.js';
2
+ export type PromoteMessageKey = 'cli.promote.kind_unsupported' | 'cli.promote.not_managed' | 'cli.promote.blocked_header' | 'cli.promote.drifted' | 'cli.promote.no_current_evidence' | 'cli.promote.incomparable' | 'cli.promote.incomparable_unverified' | 'cli.promote.verdict_blocked' | 'cli.promote.force_hint' | 'cli.promote.force_reason_required' | 'cli.promote.promoted' | 'cli.promote.forced_override' | 'cli.promote.already_promoted';
3
+ export declare const promoteDict: Record<PromoteMessageKey, CliMessage>;