@ninkch/dsh-artcraft 0.2.7 → 0.2.8

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.
package/client.js CHANGED
@@ -62,10 +62,19 @@ window.__ModuleLoader__.load({
62
62
  useEffect(() => () => { alive.current = false; }, []);
63
63
 
64
64
  const loadStatus = useCallback(async () => {
65
+ setBusy('refresh');
65
66
  try {
67
+ // 刷新 = 状态 + 顺带一次家族安装探测(后装软件/改安装目录后自动修正配置;
68
+ // 探测端点在旧版插件上不存在时静默跳过)
69
+ try {
70
+ await api('/discover', {
71
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
72
+ });
73
+ } catch { /* 旧版无此路由 → 静默跳过 */ }
66
74
  const s = await api('/status');
67
75
  if (alive.current) { setStatus(s); setStatusErr(null); }
68
76
  } catch (e) { if (alive.current) setStatusErr(String(e.message || e)); }
77
+ finally { if (alive.current) setBusy(''); }
69
78
  }, []);
70
79
 
71
80
  const checkUpdate = useCallback(async () => {
package/index.js CHANGED
@@ -42,7 +42,7 @@ import crypto from 'node:crypto';
42
42
  import { fileURLToPath } from 'node:url';
43
43
 
44
44
  const NAME = 'artcraft';
45
- const VERSION = '0.2.7';
45
+ const VERSION = '0.2.8';
46
46
  const HERE = path.dirname(fileURLToPath(import.meta.url));
47
47
 
48
48
  const BINS = ['photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft', 'designcraft'];
@@ -70,6 +70,11 @@ const DEFAULT_CONFIG = {
70
70
  control: { ...DEFAULT_PORTS },
71
71
  };
72
72
 
73
+ const APP_DISPLAY = {
74
+ photocraft: 'PhotoCraft', vectorcraft: 'VectorCraft', filmcraft: 'FilmCraft',
75
+ lightcraft: 'LightCraft', pdfcraft: 'PdfCraft', effectcraft: 'EffectCraft', designcraft: 'DesignCraft',
76
+ };
77
+
73
78
  /* ============================ 配置 ============================ */
74
79
 
75
80
  async function loadConfig() {
@@ -77,6 +82,7 @@ async function loadConfig() {
77
82
  bins: { ...DEFAULT_CONFIG.bins },
78
83
  control: { ...DEFAULT_PORTS },
79
84
  configPath: null, // 实际生效的配置文件(供界面显示"配置目录")
85
+ autoDiscovered: false, // 首次运行是否由自动探测生成了配置
80
86
  };
81
87
  // 配置查找顺序:~/.dsh(持久,推荐 —— 更新替换包目录也不丢)→ 包目录(旧版兼容)
82
88
  const candidates = [
@@ -101,9 +107,46 @@ async function loadConfig() {
101
107
  break; // 第一个可解析的配置生效
102
108
  } catch { /* 该候选不存在/非法 → 试下一个 */ }
103
109
  }
110
+ // 首次运行(没有任何配置文件):自动探测本机家族软件安装位置并生成持久配置
111
+ if (!cfg.configPath) {
112
+ try {
113
+ const found = await discoverBinPaths();
114
+ const target = path.join(os.homedir(), '.dsh', 'artcraft.config.json');
115
+ await fs.mkdir(path.dirname(target), { recursive: true });
116
+ await fs.writeFile(target, JSON.stringify({ bins: { ...cfg.bins, ...found }, control: { ...cfg.control } }, null, 2) + '\n');
117
+ for (const k of Object.keys(found)) cfg.bins[k] = found[k];
118
+ cfg.configPath = target;
119
+ cfg.autoDiscovered = Object.keys(found).length > 0;
120
+ } catch { /* 自动探测失败不影响默认行为 */ }
121
+ }
104
122
  return cfg;
105
123
  }
106
124
 
125
+ /** 自动探测:扫描常见安装位置(Program Files 等)里的家族 CLI */
126
+ async function discoverBinPaths() {
127
+ const isWin = process.platform === 'win32';
128
+ const pf = process.env['ProgramFiles'] || (isWin ? 'C:\\Program Files' : '/usr/local/bin');
129
+ const pf86 = process.env['ProgramFiles(x86)'] || '';
130
+ const lap = process.env['LOCALAPPDATA']
131
+ ? path.join(process.env['LOCALAPPDATA'], 'Programs')
132
+ : path.join(os.homedir(), '.local');
133
+ const roots = [pf, ...(pf86 ? [pf86] : []), lap];
134
+ const found = {};
135
+ for (const key of BINS) {
136
+ const cliName = cliNameFor(key);
137
+ const names = [APP_DISPLAY[key], key, key.toUpperCase(), key.toLowerCase()];
138
+ outer: for (const root of roots) {
139
+ for (const name of names) {
140
+ const exe = path.join(root, name, cliName);
141
+ try {
142
+ if ((await fs.stat(exe)).isFile()) { found[key] = path.join(root, name); break outer; }
143
+ } catch { /* 继续 */ }
144
+ }
145
+ }
146
+ }
147
+ return found;
148
+ }
149
+
107
150
  /* ============================ 路径与进程 ============================ */
108
151
 
109
152
  /**
@@ -421,7 +464,7 @@ async function statusInfo(cfg, meta) {
421
464
  plugin: NAME,
422
465
  version: VERSION,
423
466
  platform: process.platform,
424
- config: { path: configPath, found: !!configPath },
467
+ config: { path: configPath, found: !!configPath, autoDiscovered: !!cfg.autoDiscovered },
425
468
  bins,
426
469
  control: cfg.control,
427
470
  jobs: {
@@ -533,17 +576,30 @@ async function detectInstall() {
533
576
  return { mode: repoDir ? 'git' : 'npm', here, real, repoDir, profileDir, gitClean, branch };
534
577
  }
535
578
 
579
+ const REGISTRIES = ['https://registry.npmjs.org', 'https://registry.npmmirror.com'];
580
+
536
581
  async function fetchLatestVersion() {
537
- const url = `https://registry.npmjs.org/${PKG_NAME.replace('/', '%2f')}/latest`;
538
- try {
539
- const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
540
- if (res.status === 404) return { latest: null, note: 'npm 上还没有这个包' };
541
- if (!res.ok) throw new Error('registry 返回 ' + res.status);
542
- const j = await res.json();
543
- return { latest: j.version ?? null, tarball: j.dist?.tarball ?? null, integrity: j.dist?.integrity ?? null };
544
- } catch (e) {
545
- return { latest: null, note: '查询 npm 失败: ' + String(e?.cause?.message || e?.message || e) };
582
+ const slug = PKG_NAME.replace('/', '%2f');
583
+ const errors = [];
584
+ // 回退链:官方源 → npmmirror 镜像(国内无代理机器也能完成检查与更新)
585
+ for (const reg of REGISTRIES) {
586
+ try {
587
+ const res = await fetch(`${reg}/${slug}/latest`, { signal: AbortSignal.timeout(12000) });
588
+ if (res.status === 404) { errors.push(`${reg}: 404`); continue; }
589
+ if (!res.ok) throw new Error('HTTP ' + res.status);
590
+ const j = await res.json();
591
+ if (!j.version) { errors.push(`${reg}: 无版本数据`); continue; }
592
+ return {
593
+ latest: j.version ?? null,
594
+ tarball: j.dist?.tarball ?? null,
595
+ integrity: j.dist?.integrity ?? null,
596
+ source: reg,
597
+ };
598
+ } catch (e) {
599
+ errors.push(`${reg}: ${String(e?.cause?.message || e?.message || e)}`);
600
+ }
546
601
  }
602
+ return { latest: null, note: '查询失败(官方源与 npmmirror 均不可达)—— ' + errors.join(';') };
547
603
  }
548
604
 
549
605
  async function updateCheck() {
@@ -585,8 +641,20 @@ async function updateApply() {
585
641
  await fs.mkdir(tmp, { recursive: true });
586
642
  try {
587
643
  const tgzPath = path.join(tmp, 'pkg.tgz');
588
- const res = await fetch(meta.tarball, { signal: AbortSignal.timeout(180000) });
589
- if (!res.ok) throw new Error('下载 tarball 失败: HTTP ' + res.status);
644
+ // 下载:优先元数据自带的官方地址;失败时回退 npmmirror 镜像(sha512 校验保证内容一致)
645
+ let res;
646
+ try {
647
+ res = await fetch(meta.tarball, { signal: AbortSignal.timeout(180000) });
648
+ if (!res.ok) throw new Error('HTTP ' + res.status);
649
+ } catch (e) {
650
+ const slug = PKG_NAME.replace('/', '%2f');
651
+ const mirrorUrl = `https://registry.npmmirror.com/${slug}/-/${PKG_NAME.split('/')[1]}-${meta.latest}.tgz`;
652
+ WriteMirrorRetry: {
653
+ res = await fetch(mirrorUrl, { signal: AbortSignal.timeout(180000) });
654
+ if (!res.ok) throw new Error('HTTP ' + res.status);
655
+ break WriteMirrorRetry;
656
+ }
657
+ }
590
658
  const buf = Buffer.from(await res.arrayBuffer());
591
659
  if (buf.length > 64 * 1024 * 1024) throw new Error('tarball 超过 64MB 上限,已终止');
592
660
  const digest = crypto.createHash('sha512').update(buf).digest('base64');
@@ -793,6 +861,63 @@ export function apply(ctx) {
793
861
  },
794
862
  });
795
863
 
864
+ attempt({
865
+ name: 'artcraft_discover',
866
+ description: '自动探测本机 ArtCraft 家族软件安装位置(Program Files / LocalAppData\\Programs 等),写入/修复配置:填充默认空缺项,并自动纠正已失效的路径(改安装目录、卸载重装到别处);用户显式配置仍有效则不动。',
867
+ schema: { type: 'object', properties: {}, additionalProperties: false },
868
+ async execute() {
869
+ const cfg = await loadConfig();
870
+ const found = await discoverBinPaths();
871
+ const applied = {};
872
+ for (const k of BINS) {
873
+ if (!found[k]) continue;
874
+ let effective = null;
875
+ try { effective = await resolveBin(cfg, k); } catch { /* 未配置 */ }
876
+ const ok = effective ? await fileExists(effective) : false;
877
+ if (ok) continue; // 现有配置仍有效 → 不动
878
+ applied[k] = found[k];
879
+ }
880
+ if (!Object.keys(applied).length) {
881
+ return { applied: {}, configPath: cfg.configPath, note: '家族程序路径均已就绪,无需更新' };
882
+ }
883
+ const target = cfg.configPath || path.join(os.homedir(), '.dsh', 'artcraft.config.json');
884
+ let obj = {};
885
+ try { obj = JSON.parse(await fs.readFile(target, 'utf8')); } catch { /* 新文件 */ }
886
+ obj.bins = { ...(obj.bins ?? {}), ...applied };
887
+ await fs.mkdir(path.dirname(target), { recursive: true });
888
+ await fs.writeFile(target, JSON.stringify(obj, null, 2) + '\n');
889
+ return { applied, configPath: target, note: '已写入配置,立即生效' };
890
+ },
891
+ });
892
+
893
+ attempt({
894
+ name: 'artcraft_recipes',
895
+ description: '内置专家处理方案库:ArtCraft 家族的专业工作流配方(短剧海报、分镜批量调色、横转竖、响度标准化、SRT 字幕烧录、动态片头、多平台分发包、先探测后验证纪律)。action=list 列出全部;action=get + id 取单个配方的专业原则、执行步骤与验证清单。',
896
+ schema: {
897
+ type: 'object',
898
+ properties: {
899
+ action: { type: 'string', enum: ['list', 'get'] },
900
+ id: { type: 'string', description: 'action=get 时的配方 id' },
901
+ },
902
+ required: ['action'],
903
+ additionalProperties: false,
904
+ },
905
+ async execute(args) {
906
+ const recipesPath = path.join(HERE, 'recipes', 'recipes.json');
907
+ const catalog = JSON.parse(await fs.readFile(recipesPath, 'utf8'));
908
+ if (String(args.action) === 'get') {
909
+ const r = catalog.recipes.find((x) => x.id === String(args.id ?? ''));
910
+ if (!r) throw new Error('配方不存在: ' + args.id + '(可用: ' + catalog.recipes.map((x) => x.id).join(', ') + ')');
911
+ return r;
912
+ }
913
+ return {
914
+ version: catalog.version,
915
+ recipes: catalog.recipes.map((r) => ({ id: r.id, title: r.title, apps: r.apps, goal: r.goal })),
916
+ hint: 'action=get + id 取完整配方(专业原则 + 执行步骤 + 验证清单)',
917
+ };
918
+ },
919
+ });
920
+
796
921
  return () => {
797
922
  for (const d of disposers) { try { d(); } catch { /* ignore */ } }
798
923
  };
@@ -939,6 +1064,36 @@ export function apply(ctx) {
939
1064
  sendJson(res, 200, await updateApply());
940
1065
  }),
941
1066
 
1067
+ makeRoute(`/${NAME}/api/discover`, async (req, res) => {
1068
+ if (req.method !== 'POST') throw new Error('仅支持 POST');
1069
+ const cfg = await loadConfig();
1070
+ const found = await discoverBinPaths();
1071
+ // 只填充默认空缺项;显式配置仅在已失效(改安装目录/卸载重装)时自动纠正,有效则不动
1072
+ const applied = {};
1073
+ for (const k of BINS) {
1074
+ if (!found[k]) continue;
1075
+ let effective = null;
1076
+ try { effective = await resolveBin(cfg, k); } catch { /* 未配置 */ }
1077
+ const ok = effective ? await fileExists(effective) : false;
1078
+ if (ok) continue;
1079
+ applied[k] = found[k];
1080
+ }
1081
+ if (Object.keys(applied).length) {
1082
+ const target = cfg.configPath || path.join(os.homedir(), '.dsh', 'artcraft.config.json');
1083
+ let obj = {};
1084
+ try { obj = JSON.parse(await fs.readFile(target, 'utf8')); } catch { /* 新文件 */ }
1085
+ obj.bins = { ...(obj.bins ?? {}), ...applied };
1086
+ await fs.mkdir(path.dirname(target), { recursive: true });
1087
+ await fs.writeFile(target, JSON.stringify(obj, null, 2) + '\n');
1088
+ }
1089
+ sendJson(res, 200, {
1090
+ discovered: found,
1091
+ applied,
1092
+ configPath: cfg.configPath,
1093
+ note: Object.keys(applied).length ? '已探测并写入配置,立即生效' : '没有需要更新的家族程序',
1094
+ });
1095
+ }),
1096
+
942
1097
  makeRoute(`/${NAME}/api/control`, async (req, res) => {
943
1098
  if (req.method !== 'POST') throw new Error('仅支持 POST');
944
1099
  const cfg = await loadConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninkch/dsh-artcraft",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "Drive the open-source ArtCraft studio apps (PhotoCraft / FilmCraft / EffectCraft) from DeepSeek Harness ? agent tools, a same-origin HTTP API, and plugins-page status with self-update.",
6
6
  "keywords": [
@@ -35,6 +35,7 @@
35
35
  "client.js",
36
36
  "cordis.patch.yml",
37
37
  "artcraft.config.json.example",
38
+ "recipes",
38
39
  "README.md",
39
40
  "README.zh-CN.md",
40
41
  "NOTICE.md"
@@ -0,0 +1,143 @@
1
+ {
2
+ "version": 1,
3
+ "updated": "2026-10-10",
4
+ "recipes": [
5
+ {
6
+ "id": "poster-short-drama",
7
+ "title": "短剧海报(竖版)",
8
+ "apps": ["photocraft"],
9
+ "goal": "生成竖版短剧海报:标题层级、装饰元素、无损输出",
10
+ "principles": [
11
+ "视觉层级:主标题 → 副标题 → 装饰元素,字号/字重至少差 2 级,同屏不超过 3 个信息层",
12
+ "对比度:文字与背景亮度差 ≥ 4.5:1,浅背景加深色字,深背景加浅色字,或加投影/描边兜底",
13
+ "安全边距:四周留 5% 不放关键内容(平台裁切友好)",
14
+ "中文字体显式指定(如 Noto Sans SC),不要依赖默认字体(缺 CJK 字形会变方框)",
15
+ "HTTP 传中文参数一律走 UTF-8 字节体,避免编码损坏"
16
+ ],
17
+ "steps": [
18
+ "photocraft-cli run --new '{\"width\":1080,\"height\":1440}' --cmd <背景填充/渐变命令> --out <工作空间>/poster.png",
19
+ "标题:Noto Sans SC,主标题字号 ≥ 120,副标题 ≤ 48;装饰条用像素层绘制",
20
+ "逐层 --cmd 叠加(每条 --params 作用于前一个 --cmd),最后 --out 输出 PNG 无损"
21
+ ],
22
+ "checks": ["取回产物做视觉检查:字形完整、层级清晰、四边安全区无关键内容"]
23
+ },
24
+ {
25
+ "id": "storyboard-color-grade",
26
+ "title": "分镜批量调色(一致性)",
27
+ "apps": ["photocraft"],
28
+ "goal": "整批分镜图统一色调,保持跨镜一致性",
29
+ "principles": [
30
+ "先定基调再微调:一条柔和 S 曲线 + 少量 vibrance,避免逐张各调各的",
31
+ "保护肤色:调整幅度宁小勿大,人脸偏色比不够风格化更伤观感",
32
+ "锐化放在调色之后(amount 60-80),避免锐化放大噪点",
33
+ "actions.json 一处定义、整批复用,保证批次内完全一致"
34
+ ],
35
+ "steps": [
36
+ "写 craft/grade.json:[{\"cmd\":\"layer.newAdjustmentLayer.curves\",\"params\":{\"points\":[[0,0],[64,48],[192,212],[255,255]]}},{\"cmd\":\"filter.sharpen.smartSharpen\",\"params\":{\"amount\":80}}]",
37
+ "photocraft-cli batch --actions <项目>/craft/grade.json --in <项目>/media --out <项目>/out/graded",
38
+ "抽 2-3 张对比原图检查,不满意只调曲线中段(64,48 / 192,212 两点)重跑"
39
+ ],
40
+ "checks": ["抽样对比:同场景相邻分镜色调一致;高光不过曝、暗部不死黑"]
41
+ },
42
+ {
43
+ "id": "vertical-reframe",
44
+ "title": "横转竖重构图",
45
+ "apps": ["filmcraft", "photocraft"],
46
+ "goal": "16:9 素材适配 9:16 竖屏,三种策略按画面主体选择",
47
+ "principles": [
48
+ "策略选择:主体居中 → 居中裁切;主体偏侧 → 位移裁切(保主体);多主体/全景 → 模糊填充(上原图下补景,不裁掉信息)",
49
+ "人脸安全:裁切框上缘留在头顶上方 10%,下缘到胸口",
50
+ "分辨率对齐目标平台:抖音/快手/视频号 1080×1920@30fps"
51
+ ],
52
+ "steps": [
53
+ "filmcraft-cli probe <素材> 先量分辨率/时长/帧率(永远先探测再动手)",
54
+ "序列设 1080×1920@30,放置后按位移/缩放保留主体",
55
+ "导出 H.264:--quality 80 起步"
56
+ ],
57
+ "checks": ["导出后 probe 验证分辨率/时长;抽帧目检主体完整、不切脸"]
58
+ },
59
+ {
60
+ "id": "loudness-web",
61
+ "title": "网络平台响度标准化",
62
+ "apps": ["filmcraft"],
63
+ "goal": "成片响度对齐平台标准,避免用户手动调音量",
64
+ "principles": [
65
+ "主流短视频平台目标:integrated -14 LUFS,true peak ≤ -1 dBTP",
66
+ "导出时一次完成(file.exportMedia loudnessLufs 参数),不要二次压限",
67
+ "BGM 与人声分层:人声为主轨,BGM 侧链闪避 6-10 dB"
68
+ ],
69
+ "steps": [
70
+ "exec file.exportMedia {\"path\":<out>,\"format\":\"h264\",\"loudnessLufs\":-14,\"wait\":true}",
71
+ "probe 产物核对时长/编码/音轨"
72
+ ],
73
+ "checks": ["probe 报告音轨存在且响度参数已应用;多平台分发时每平台独立导出"]
74
+ },
75
+ {
76
+ "id": "srt-burn",
77
+ "title": "字幕烧录(SRT)",
78
+ "apps": ["filmcraft"],
79
+ "goal": "SRT 字幕导入并烧录进成片",
80
+ "principles": [
81
+ "单行 ≤ 15 个中文字符,超长拆条;每条最短停留 1 秒",
82
+ "位置:底部安全区,避开平台 UI(进度条/互动栏约占底部 15%)",
83
+ "样式统一:描边 2-3px 或半透明底条,全片一种字体"
84
+ ],
85
+ "steps": [
86
+ "exec captions.import {\"path\":<srt 绝对路径>}",
87
+ "可选 exec captions.setStyle {\"font\":…,\"size\":…,\"outline\":…}",
88
+ "exec file.exportMedia {\"path\":<out>,\"format\":\"h264\",\"burnCaptions\":true,\"wait\":true}"
89
+ ],
90
+ "checks": ["抽帧目检字幕:无溢出、无遮挡主体、时间轴同步"]
91
+ },
92
+ {
93
+ "id": "title-motion",
94
+ "title": "动态片头/花字(EffectCraft)",
95
+ "apps": ["effectcraft"],
96
+ "goal": "生成动态片头,叠加回剪辑或直接使用",
97
+ "principles": [
98
+ ".ecproj 是版本化 JSON —— 直接生成或模板替换文本,比逐帧描述高效",
99
+ "动画时长 1.5-3s:入场 0.3-0.5s 缓动(ease),停留 ≥ 1s,出场 0.3s",
100
+ "双出口:MP4 直用;或导出 Lottie 嵌网页预览;带 alpha 的 ProRes 4444 可叠进 FilmCraft 当图形层"
101
+ ],
102
+ "steps": [
103
+ "生成/填充 intro.ecproj(composition 尺寸对齐主序列)",
104
+ "effectcraft-cli render --comp Main --out <项目>/out/intro.mp4 <项目>/craft/intro.ecproj",
105
+ "或 exec file.exportLottie 导出 .json 给网页端播放"
106
+ ],
107
+ "checks": ["render 后 probe 时长/分辨率;视觉检查动画节奏(入场不突兀、停留可读完整标题)"]
108
+ },
109
+ {
110
+ "id": "delivery-pack",
111
+ "title": "多平台分发包",
112
+ "apps": ["filmcraft"],
113
+ "goal": "同一成片产出多平台版本(分辨率/帧率/响度/命名规范)",
114
+ "principles": [
115
+ "平台参数表:抖音/快手/视频号 1080×1920@30 -14LUFS;B 站/西瓜 1920×1080@30 -14LUFS",
116
+ "命名规范:<项目>_<平台>_v<序号>.mp4,一次导出成组不散落",
117
+ "每平台独立导出(各自过 loudness 与 probe),不要一份文件多处转发"
118
+ ],
119
+ "steps": [
120
+ "逐平台 exec file.exportMedia {\"path\":<out>,\"format\":\"h264\",\"width\":…,\"height\":…,\"loudnessLufs\":-14,\"wait\":true}",
121
+ "每个产物跑一次 probe 核对"
122
+ ],
123
+ "checks": ["probe 全部产物:分辨率/帧率/音轨符合平台表;文件命名一致"]
124
+ },
125
+ {
126
+ "id": "probe-verify",
127
+ "title": "通用纪律:先探测、后验证",
128
+ "apps": ["filmcraft", "photocraft", "effectcraft"],
129
+ "goal": "所有媒体操作的通用工作流纪律(来自专业剪辑技能的通用实践)",
130
+ "principles": [
131
+ "先探测再动手:永远先 probe 素材拿真实时长/分辨率/帧率/音轨,不从文件名猜",
132
+ "能无损不重编:剪切片段优先流复制,只在必须(精确剪切/滤镜/换格式)时重编码",
133
+ "做完必验证:产物 probe 一次 + 关键帧目检,报告里写明验证结果",
134
+ "诚实标注:成功是 Done、失败是 Failed,部分完成必须写出缺口"
135
+ ],
136
+ "steps": [
137
+ "任何处理前:filmcraft-cli probe <media>",
138
+ "处理后:filmcraft-cli probe <out> + 抽帧目检"
139
+ ],
140
+ "checks": ["probe 输出与预期一致;差异写入报告而不是含糊带过"]
141
+ }
142
+ ]
143
+ }