Przeglądaj źródła

feat: add fmode image set skill

Yi Jiarui 1 miesiąc temu
rodzic
commit
4a3545b86d
50 zmienionych plików z 6963 dodań i 0 usunięć
  1. 8 0
      claude-code/fmode-image-set/.claude-plugin/plugin.json
  2. 8 0
      claude-code/fmode-image-set/.gitignore
  3. 11 0
      claude-code/fmode-image-set/.mcp.json
  4. 99 0
      claude-code/fmode-image-set/README.md
  5. 131 0
      claude-code/fmode-image-set/bin/fmode-image-set.js
  6. 47 0
      claude-code/fmode-image-set/docs/capability-map.md
  7. 31 0
      claude-code/fmode-image-set/docs/customer-quickstart.md
  8. 44 0
      claude-code/fmode-image-set/docs/deliverables/image-set-result.md
  9. 41 0
      claude-code/fmode-image-set/docs/live-manual-acceptance-checklist.md
  10. 110 0
      claude-code/fmode-image-set/install.js
  11. 25 0
      claude-code/fmode-image-set/mcp/src/core/result-envelope.mjs
  12. 167 0
      claude-code/fmode-image-set/mcp/src/core/run-paths.mjs
  13. 68 0
      claude-code/fmode-image-set/mcp/src/core/run-paths.test.mjs
  14. 38 0
      claude-code/fmode-image-set/mcp/src/core/run-state-integrity.mjs
  15. 14 0
      claude-code/fmode-image-set/mcp/src/core/run-state-integrity.test.mjs
  16. 127 0
      claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.mjs
  17. 24 0
      claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.test.mjs
  18. 394 0
      claude-code/fmode-image-set/mcp/src/features/image-set/product-vision-client.mjs
  19. 523 0
      claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.mjs
  20. 187 0
      claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.test.mjs
  21. 287 0
      claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.mjs
  22. 213 0
      claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.test.mjs
  23. 147 0
      claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.mjs
  24. 59 0
      claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.test.mjs
  25. 764 0
      claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.mjs
  26. 147 0
      claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.test.mjs
  27. 169 0
      claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.mjs
  28. 524 0
      claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.test.mjs
  29. 91 0
      claude-code/fmode-image-set/mcp/src/server.mjs
  30. 16 0
      claude-code/fmode-image-set/mcp/src/tools/create-run.mjs
  31. 141 0
      claude-code/fmode-image-set/mcp/src/tools/image-set-run.mjs
  32. 50 0
      claude-code/fmode-image-set/mcp/src/tools/image-set-validate.mjs
  33. 16 0
      claude-code/fmode-image-set/memory-templates/image-set-profile.json
  34. 1188 0
      claude-code/fmode-image-set/package-lock.json
  35. 53 0
      claude-code/fmode-image-set/package.json
  36. 15 0
      claude-code/fmode-image-set/scripts/acceptance.mjs
  37. 75 0
      claude-code/fmode-image-set/scripts/package.test.mjs
  38. 27 0
      claude-code/fmode-image-set/scripts/run-tests.mjs
  39. 32 0
      claude-code/fmode-image-set/scripts/smoke-mcp.mjs
  40. 60 0
      claude-code/fmode-image-set/scripts/smoke-package.mjs
  41. 142 0
      claude-code/fmode-image-set/scripts/test-plan-fixture.mjs
  42. 35 0
      claude-code/fmode-image-set/skill-package-manifest.json
  43. 105 0
      claude-code/fmode-image-set/skills/fmode-image-set/SKILL.md
  44. 4 0
      claude-code/fmode-image-set/skills/fmode-image-set/agents/openai.yaml
  45. 130 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/image-set-planner.md
  46. 36 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/interaction.md
  47. 62 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/jimeng-v4.md
  48. 115 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/plan-schema.md
  49. 42 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/product-understanding.md
  50. 121 0
      claude-code/fmode-image-set/skills/fmode-image-set/references/prompt-compiler.md

+ 8 - 0
claude-code/fmode-image-set/.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+  "name": "fmode-image-set",
+  "description": "Product-truth-first ecommerce image-set planning and generation for Claude Code, with visual auditing, seven-slot prompt planning, Jimeng execution, recovery, cost control, and delivery integrity checks.",
+  "version": "0.1.0",
+  "author": {
+    "name": "Fmode"
+  }
+}

+ 8 - 0
claude-code/fmode-image-set/.gitignore

@@ -0,0 +1,8 @@
+node_modules/
+生成结果/
+memory/
+outputs/
+.env
+.env.local
+.tmp/
+*.tgz

+ 11 - 0
claude-code/fmode-image-set/.mcp.json

@@ -0,0 +1,11 @@
+{
+  "mcpServers": {
+    "fmode-image-set": {
+      "command": "node",
+      "args": [
+        "D:\\Angular\\openclaw-voc-skill\\claude-code\\fmode-image-set\\mcp\\src\\server.mjs"
+      ],
+      "cwd": "D:\\Angular\\openclaw-voc-skill\\claude-code\\fmode-image-set"
+    }
+  }
+}

+ 99 - 0
claude-code/fmode-image-set/README.md

@@ -0,0 +1,99 @@
+# Fmode Image Set
+
+面向 Claude Code 的电商商品套图 Skill。用户提供一张商品图和一句需求,系统完成商品事实审计、七图规划、逐图 Prompt 校验,并在用户明确授权后调用即梦生成。
+
+## 能力
+
+- 识别商品可见事实,分离推断与未知项;
+- 锁定商品外观、Logo、包装文字和不可新增结构;
+- 规划 1 张主图、2 张场景图、2 张卖点图、2 张购买决策图;
+- 校验卖点证据、角色配额、构图多样性和 Prompt 完整性;
+- 串行调用即梦并下载成品;
+- 对生成商品进行身份复核;
+- 支持同一运行恢复、费用上限和交付完整性审计。
+
+## 目录
+
+```text
+.claude-plugin/          Claude Code 插件元数据
+bin/                     安装与命令行入口
+docs/                    能力地图、快速开始、验收和交付物合同
+mcp/src/core/            路径、状态签名和结果信封
+mcp/src/features/        套图编排、视觉理解和计划校验
+mcp/src/providers/       Claude 附件、视觉模型、七牛、即梦适配
+mcp/src/tools/           MCP/CLI 薄入口
+memory-templates/        默认套图偏好模板
+scripts/                 smoke 与 acceptance
+skills/fmode-image-set/  Skill 入口、references 和 Agent 元数据
+```
+
+## 安装
+
+当前目录检查:
+
+```powershell
+npm install
+npm run install:check
+npm run smoke:package
+npm run mcp:smoke
+```
+
+安装到当前 Claude Code 工作区:
+
+```powershell
+node bin/fmode-image-set.js workspace --smoke
+```
+
+安装后会写入:
+
+```text
+.mcp.json
+.claude/skills/fmode-image-set/   完整交付包,同时包含根级 Skill 发现入口
+```
+
+## 使用
+
+在 Claude Code 中上传商品图并说:
+
+```text
+给这款商品做一套高级、适合电商详情页的七图。
+```
+
+只规划、不生图:
+
+```text
+先帮我规划这套图,不要实际生图。
+```
+
+## MCP 工具
+
+- `fmode_image_set_run`
+  - `start`:创建运行并完成商品视觉理解;
+  - `execute`:在计划通过且用户授权后生图;
+  - `resume`:恢复同一个运行;
+  - `status`:只读查看状态。
+- `fmode_image_set_validate`
+  - 校验 `prompt-plan.json`,不调用生图模型。
+
+## 运行产物
+
+默认写入项目目录:
+
+```text
+生成结果/电商七图/<run_id>/
+├── 商品附件/
+├── 成品图/
+├── vision-result.json
+├── prompt-plan.json
+├── reference-assets.json
+└── jimeng-result.json
+```
+
+## 安全边界
+
+- 未经用户明确授权不执行付费生图;
+- 默认费用上限为 1.54 元;
+- `submission_unknown` 不自动重提;
+- 商品身份复核失败的图片不作为合格交付;
+- 不复制其他运行的图片补齐当前运行;
+- Token 不进入计划、报告或日志。

+ 131 - 0
claude-code/fmode-image-set/bin/fmode-image-set.js

@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SOURCE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const INVOCATION_ROOT = path.resolve(process.cwd());
+const invocationRelativeToSource = path.relative(SOURCE_ROOT, INVOCATION_ROOT);
+const invokedFromSource = invocationRelativeToSource === ""
+  || (!invocationRelativeToSource.startsWith("..") && !path.isAbsolute(invocationRelativeToSource));
+const WORKSPACE_ROOT = invokedFromSource ? path.dirname(SOURCE_ROOT) : INVOCATION_ROOT;
+const DEFAULT_TARGET = path.join(os.homedir(), ".claude", "plugins", "fmode-image-set");
+const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, ".claude", "skills", "fmode-image-set");
+const EXCLUDED = new Set(["node_modules", "生成结果", "memory", "outputs", ".tmp", ".git", ".env", ".env.local"]);
+EXCLUDED.add(".claude");
+EXCLUDED.add(".codex");
+
+function parseArgs(argv) {
+  const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : "install";
+  const options = {
+    command,
+    target: command === "workspace" ? WORKSPACE_TARGET : DEFAULT_TARGET,
+    workspace: command === "workspace",
+    smoke: argv.includes("--smoke"),
+    skipInstall: argv.includes("--skip-install"),
+    force: argv.includes("--force"),
+  };
+  const targetIndex = argv.indexOf("--target");
+  if (targetIndex >= 0 && argv[targetIndex + 1]) options.target = path.resolve(argv[targetIndex + 1]);
+  return options;
+}
+
+function isInside(parent, candidate) {
+  const relative = path.relative(path.resolve(parent), path.resolve(candidate));
+  return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
+}
+
+function safeTarget(target, force) {
+  return force
+    || path.resolve(target) === path.resolve(DEFAULT_TARGET)
+    || isInside(path.join(WORKSPACE_ROOT, ".claude", "plugins"), target)
+    || isInside(path.join(WORKSPACE_ROOT, ".claude", "skills"), target);
+}
+
+function copyPackage(source, target) {
+  fs.mkdirSync(target, { recursive: true });
+  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
+    if (EXCLUDED.has(entry.name) || entry.name.endsWith(".tgz")) continue;
+    const from = path.join(source, entry.name);
+    const to = path.join(target, entry.name);
+    if (entry.isDirectory()) copyPackage(from, to);
+    else if (entry.isFile()) fs.copyFileSync(from, to);
+  }
+}
+
+function run(command, args, cwd) {
+  const useCmd = process.platform === "win32" && command === "npm";
+  const executable = useCmd ? "cmd.exe" : command;
+  const finalArgs = useCmd ? ["/d", "/s", "/c", "npm", ...args] : args;
+  const child = spawnSync(executable, finalArgs, { cwd, stdio: "inherit", encoding: "utf8" });
+  if (child.status !== 0) throw new Error(`${command} failed with exit ${child.status}`);
+}
+
+function readJsonMaybe(filePath) {
+  if (!fs.existsSync(filePath)) return {};
+  return JSON.parse(fs.readFileSync(filePath, "utf8"));
+}
+
+function writeWorkspaceActivation(target) {
+  const configPath = path.join(WORKSPACE_ROOT, ".mcp.json");
+  const existing = readJsonMaybe(configPath);
+  const config = {
+    ...existing,
+    mcpServers: {
+      ...(existing.mcpServers || {}),
+      "fmode-image-set": {
+        command: "node",
+        args: [path.join(target, "mcp", "src", "server.mjs")],
+        cwd: target,
+      },
+    },
+  };
+  fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
+
+  const packagedSkill = path.join(target, "skills", "fmode-image-set");
+  copyPackage(packagedSkill, target);
+}
+
+function install(options) {
+  if (fs.existsSync(options.target)) {
+    if (!safeTarget(options.target, options.force)) {
+      throw new Error(`Target exists; use --force only for an intentional custom replacement: ${options.target}`);
+    }
+    fs.rmSync(options.target, { recursive: true, force: true });
+  }
+  copyPackage(SOURCE_ROOT, options.target);
+  if (options.workspace) writeWorkspaceActivation(options.target);
+  if (!options.skipInstall) run("npm", ["install", "--omit=dev", "--ignore-scripts"], options.target);
+  run(process.execPath, ["install.js", options.smoke ? "--smoke" : "--check", "--skip-install"], options.target);
+  console.log(`Installed Fmode Image Set: ${options.target}`);
+  if (options.workspace) console.log("Restart the Claude Code session so it can discover the new Skill and MCP tools.");
+}
+
+function main() {
+  const options = parseArgs(process.argv.slice(2));
+  if (options.command === "install" || options.command === "workspace") return install(options);
+  if (options.command === "check") return run(process.execPath, ["install.js", "--check"], SOURCE_ROOT);
+  if (options.command === "smoke") return run(process.execPath, ["install.js", "--smoke", "--skip-install"], SOURCE_ROOT);
+  if (options.command === "path") return console.log(options.target);
+  if (options.command === "run") {
+    return run(process.execPath, ["mcp/src/tools/image-set-run.mjs", ...process.argv.slice(3)], SOURCE_ROOT);
+  }
+  console.log([
+    "Usage:",
+    "  fmode-image-set install [--smoke]",
+    "  fmode-image-set workspace [--smoke]",
+    "  fmode-image-set check",
+    "  fmode-image-set smoke",
+    "  fmode-image-set run <start|execute|resume|status> [options]",
+    "  fmode-image-set path",
+  ].join("\n"));
+}
+
+try {
+  main();
+} catch (error) {
+  console.error(`fmode-image-set failed: ${error.message}`);
+  process.exit(1);
+}

+ 47 - 0
claude-code/fmode-image-set/docs/capability-map.md

@@ -0,0 +1,47 @@
+# Fmode Image Set 能力地图
+
+## 一句话定位
+
+把一张真实商品图和一句业务需求,转成有证据、有分工、可校验、可恢复的电商七图。
+
+## 用户任务到入口映射
+
+| 用户任务 | Skill | MCP 工具 | 动作 | 输出 |
+| --- | --- | --- | --- | --- |
+| 理解商品并开始规划 | `fmode-image-set` | `fmode_image_set_run` | `start` | 运行目录、视觉理解、商品身份锁 |
+| 校验七图计划 | `fmode-image-set` | `fmode_image_set_validate` | — | 校验结果与修改项 |
+| 实际生成七图 | `fmode-image-set` | `fmode_image_set_run` | `execute` | 本地图片、状态与费用 |
+| 恢复中断任务 | `fmode-image-set` | `fmode_image_set_run` | `resume` | 同运行增量结果 |
+| 查看运行状态 | `fmode-image-set` | `fmode_image_set_run` | `status` | 账本与交付完整性 |
+
+## 能力边界
+
+- 当前固定七张,不是任意数量套图引擎。
+- 当前下游执行器为即梦 4.0。
+- 没有确定性文字排版器,重要文字和数值不交给生图模型重绘。
+- 商品图可证明可见事实,不能证明功效、成分、销量和认证。
+- 竞品图只能提供设计语法,不能作为商品资产。
+
+## 文件索引
+
+| 文件 | 用途 |
+| --- | --- |
+| `skills/fmode-image-set/SKILL.md` | 触发规则、证据边界、执行纪律和用户输出 |
+| `skills/fmode-image-set/references/*` | 商品理解、七图规划、Prompt 编译、Schema、即梦执行 |
+| `mcp/src/server.mjs` | MCP 工具注册与 Zod Schema |
+| `mcp/src/tools/*` | CLI/MCP 薄入口 |
+| `mcp/src/features/image-set/*` | 套图编排、视觉理解与计划校验 |
+| `mcp/src/providers/*` | 附件、视觉、七牛、即梦 |
+| `mcp/src/core/*` | 路径门禁、状态签名和统一结果 |
+| `docs/deliverables/image-set-result.md` | 套图交付合同 |
+| `docs/live-manual-acceptance-checklist.md` | 真实接口人工验收 |
+| `scripts/*` | 包级、MCP 和安装验收 |
+
+## 发布检查点
+
+- `package.json`、插件清单、包清单和 MCP Server 版本一致;
+- `npm run smoke:package` 通过;
+- `npm run mcp:smoke` 通过;
+- `npm run acceptance` 通过;
+- 真实生图发布前完成最小人工验收;
+- 不把真实 Token、用户附件和运行结果打入 npm 包。

+ 31 - 0
claude-code/fmode-image-set/docs/customer-quickstart.md

@@ -0,0 +1,31 @@
+# 客户快速开始
+
+## 你可以这样开始
+
+上传一张清晰商品图,然后说:
+
+```text
+给这款商品做一套高级、适合电商详情页的七图。
+```
+
+只想先看方案:
+
+```text
+先规划七张图分别讲什么,不要实际生图。
+```
+
+## 你会看到什么
+
+- 一张白底主图;
+- 两张不同任务的场景图;
+- 两张有证据的卖点图;
+- 两张解决购买疑问的决策图;
+- 商品外观和整套视觉风格保持一致。
+
+## 什么时候会产生费用
+
+只有你明确要求“开始生图”后才会调用图片模型。默认七张各生成一次,费用上限为 1.54 元;需要额外重试时会先说明。
+
+## 图片不够清楚怎么办
+
+系统会指出缺少的最短补拍清单,不会为了填满七张而虚构不可见结构、规格或功效。

+ 44 - 0
claude-code/fmode-image-set/docs/deliverables/image-set-result.md

@@ -0,0 +1,44 @@
+# 商品套图交付合同
+
+## 适用场景
+
+用户提供真实商品图,希望获得一套用于电商主图、详情页或商品传播的七张图片。
+
+## 输入字段
+
+- 商品参考图;
+- 一句话业务需求;
+- 平台/市场,可选;
+- 商品官方事实,可选;
+- VOC、竞品、关键词和趋势,可选;
+- 是否授权实际生图;
+- 费用上限。
+
+## 输出结构
+
+1. 运行编号与状态;
+2. 七张图片的业务角色;
+3. 01–07 本地成品;
+4. 成品文件夹;
+5. 完成、失败、未知、拒绝和可恢复图位;
+6. 已发生费用范围;
+7. 下一步动作。
+
+内部同时保留:
+
+- `vision-result.json`
+- `prompt-plan.json`
+- `reference-assets.json`
+- `jimeng-result.json`
+
+## 验收标准
+
+- 恰好 1 张主图、2 张场景图、2 张卖点图、2 张购买决策图;
+- 商品真实性严重错误为 0;
+- 两张卖点图使用不同证据;
+- 七张图不是同一构图换背景;
+- 商品锁、可见事实和视觉审计保持一致;
+- 重要 Logo、包装文字、规格和数量没有漂移;
+- 交付文件全部来自本次运行账本;
+- 费用不超过用户授权上限;
+- 部分完成时不伪称七张全部成功。

+ 41 - 0
claude-code/fmode-image-set/docs/live-manual-acceptance-checklist.md

@@ -0,0 +1,41 @@
+# Fmode Image Set 真实执行验收清单
+
+## 验收前准备
+
+- 使用无敏感信息的测试商品;
+- 确认视觉模型、七牛和即梦凭据已配置;
+- 确认服务端余额;
+- 使用新的测试运行,不修改历史状态文件;
+- 默认费用上限保持 1.54 元。
+
+## 最小验收
+
+1. 使用一张清晰商品图执行 `start`。
+2. 检查 `vision-result.json`:
+   - 可见事实与图片一致;
+   - 推断与未知项已分离;
+   - `identity_manifest` 没有虚构结构。
+3. 编写七图计划并执行校验。
+4. 确认校验不通过时没有调用即梦。
+5. 用户明确授权后执行 `execute`。
+6. 检查每张图完成下载后才提交下一张。
+7. 检查生成后商品身份复核。
+8. 检查成品目录与账本一致。
+9. 检查最终费用范围。
+
+## 失败场景
+
+- 连接预检失败:应标记未提交,并允许同运行恢复;
+- `submission_unknown`:不得自动重提;
+- 单图明确失败:隔离后继续后续图位;
+- `quality_rejected`:不交付、不自动补图;
+- 状态被手工修改:恢复应被签名校验拒绝;
+- 从其他运行复制图片:交付审计应阻断。
+
+## 通过标准
+
+- 商品真实性严重错误为 0;
+- 计划校验与身份复核均生效;
+- 状态和费用可追踪;
+- 同一用户请求不会创建第二个运行;
+- 所有失败都有明确状态和下一步。

+ 110 - 0
claude-code/fmode-image-set/install.js

@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = path.dirname(fileURLToPath(import.meta.url));
+const MIN_NODE_MAJOR = 20;
+const REQUIRED_FILES = [
+  "package.json",
+  ".claude-plugin/plugin.json",
+  ".mcp.json",
+  "skill-package-manifest.json",
+  "skills/fmode-image-set/SKILL.md",
+  "mcp/src/server.mjs",
+  "mcp/src/tools/image-set-run.mjs",
+  "mcp/src/tools/image-set-validate.mjs",
+  "mcp/src/features/image-set/image-set-runner.mjs",
+  "mcp/src/features/image-set/product-vision-client.mjs",
+  "mcp/src/features/image-set/validate-plan.mjs",
+  "mcp/src/providers/jimeng-v4-client.mjs",
+];
+
+function parseArgs(argv) {
+  return {
+    check: argv.includes("--check"),
+    smoke: argv.includes("--smoke"),
+    skipInstall: argv.includes("--skip-install"),
+    help: argv.includes("--help") || argv.includes("-h"),
+  };
+}
+
+function run(command, args, cwd = ROOT) {
+  const useCmd = process.platform === "win32" && command === "npm";
+  const executable = useCmd ? "cmd.exe" : command;
+  const finalArgs = useCmd ? ["/d", "/s", "/c", "npm", ...args] : args;
+  const child = spawnSync(executable, finalArgs, {
+    cwd,
+    encoding: "utf8",
+    stdio: "inherit",
+    maxBuffer: 1024 * 1024 * 100,
+  });
+  if (child.status !== 0) {
+    throw new Error(`${command} ${args.join(" ")} failed with exit ${child.status}`);
+  }
+}
+
+function readJson(relativePath) {
+  return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), "utf8"));
+}
+
+function writeMcpConfig(rootDir = ROOT) {
+  const config = {
+    mcpServers: {
+      "fmode-image-set": {
+        command: "node",
+        args: [path.join(rootDir, "mcp", "src", "server.mjs")],
+        cwd: rootDir,
+      },
+    },
+  };
+  fs.writeFileSync(path.join(rootDir, ".mcp.json"), `${JSON.stringify(config, null, 2)}\n`, "utf8");
+}
+
+export function checkPackage() {
+  const major = Number(process.versions.node.split(".")[0]);
+  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
+    throw new Error(`Node.js ${MIN_NODE_MAJOR}+ is required. Current version: ${process.version}`);
+  }
+  const missing = REQUIRED_FILES.filter((relativePath) => !fs.existsSync(path.join(ROOT, relativePath)));
+  if (missing.length) throw new Error(`Missing required files: ${missing.join(", ")}`);
+
+  const packageJson = readJson("package.json");
+  const pluginJson = readJson(".claude-plugin/plugin.json");
+  const manifest = readJson("skill-package-manifest.json");
+  const serverText = fs.readFileSync(path.join(ROOT, "mcp", "src", "server.mjs"), "utf8");
+  const serverVersion = serverText.match(/const VERSION = "([^"]+)"/)?.[1] || "";
+  const versions = new Set([packageJson.version, pluginJson.version, manifest.version, serverVersion]);
+  if (versions.size !== 1) throw new Error("package, plugin, and skill manifest versions must match");
+  if (pluginJson.name !== "fmode-image-set" || manifest.name !== "fmode-image-set") {
+    throw new Error("plugin and skill manifest names must be fmode-image-set");
+  }
+  if (!manifest.skills.includes("fmode-image-set")) throw new Error("entry skill is missing from package manifest");
+  return { version: packageJson.version, files: REQUIRED_FILES.length };
+}
+
+async function main() {
+  const options = parseArgs(process.argv.slice(2));
+  if (options.help) {
+    console.log("Usage: node install.js [--check] [--smoke] [--skip-install]");
+    return;
+  }
+  const checked = checkPackage();
+  writeMcpConfig(ROOT);
+  console.log(`Fmode Image Set ${checked.version}`);
+  console.log(`Required files: OK (${checked.files})`);
+
+  if (!options.check && !options.skipInstall) {
+    run("npm", ["install", "--omit=dev", "--ignore-scripts"]);
+  }
+  if (options.smoke) {
+    run(process.execPath, ["scripts/smoke-package.mjs"]);
+    run(process.execPath, ["scripts/smoke-mcp.mjs"]);
+  }
+}
+
+main().catch((error) => {
+  console.error(`Install failed: ${error.message}`);
+  process.exit(1);
+});

+ 25 - 0
claude-code/fmode-image-set/mcp/src/core/result-envelope.mjs

@@ -0,0 +1,25 @@
+export function okResult(payload = {}) {
+  return {
+    status: "ok",
+    assistantMessage: payload.assistantMessage || "",
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: payload.files || [],
+    nextActions: payload.nextActions || [],
+    warnings: payload.warnings || [],
+    errors: payload.errors || [],
+  };
+}
+
+export function errorResult(status, message, payload = {}) {
+  return {
+    status: status || "error",
+    assistantMessage: message,
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: payload.files || [],
+    nextActions: payload.nextActions || [],
+    warnings: payload.warnings || [],
+    errors: payload.errors || [{ message }],
+  };
+}

+ 167 - 0
claude-code/fmode-image-set/mcp/src/core/run-paths.mjs

@@ -0,0 +1,167 @@
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { createHash, randomBytes } from "node:crypto";
+import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
+export const packageRoot = resolve(scriptsDirectory, "..", "..", "..");
+
+function defaultProjectRoot() {
+  const parent = resolve(packageRoot, "..");
+  const parentName = basename(parent).toLowerCase();
+  const containerName = basename(resolve(parent, "..")).toLowerCase();
+  if (parentName === "plugins" && new Set([".claude", ".codex", ".vocmarket"]).has(containerName)) {
+    return resolve(parent, "..", "..");
+  }
+  return parent;
+}
+
+export const projectRoot = resolve(
+  process.env.FMODE_IMAGE_SET_PROJECT_ROOT
+    || process.env.CLAUDE_PROJECT_DIR
+    || defaultProjectRoot(),
+);
+export const runsRoot = join(projectRoot, "生成结果", "电商七图");
+export const requestIndexRoot = join(runsRoot, ".request-index");
+
+function compactTimestamp(date) {
+  const parts = new Intl.DateTimeFormat("en-GB", {
+    timeZone: "Asia/Shanghai",
+    year: "numeric",
+    month: "2-digit",
+    day: "2-digit",
+    hour: "2-digit",
+    minute: "2-digit",
+    second: "2-digit",
+    hourCycle: "h23",
+  }).formatToParts(date);
+  const value = (type) => parts.find((part) => part.type === type)?.value ?? "";
+  return `${value("year")}${value("month")}${value("day")}${value("hour")}${value("minute")}${value("second")}`;
+}
+
+export function createRunId(date = new Date(), suffix = randomBytes(3).toString("hex")) {
+  return `run-${compactTimestamp(date)}-${suffix}`;
+}
+
+export function isInside(parent, candidate) {
+  const normalizedParent = process.platform === "win32" ? resolve(parent).toLowerCase() : resolve(parent);
+  const normalizedCandidate = process.platform === "win32" ? resolve(candidate).toLowerCase() : resolve(candidate);
+  const pathFromParent = relative(normalizedParent, normalizedCandidate);
+  return pathFromParent === "" || (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent));
+}
+
+export function assertCanonicalRunDirectory(candidate) {
+  const directory = resolve(candidate);
+  const parent = resolve(directory, "..");
+  const sameParent = process.platform === "win32"
+    ? parent.toLowerCase() === resolve(runsRoot).toLowerCase()
+    : parent === resolve(runsRoot);
+  if (!isInside(runsRoot, directory) || !sameParent) {
+    throw new Error(`RUN_PATH_INVALID: run directory must be one direct child of ${runsRoot}`);
+  }
+  if (!/^run-\d{14}-[a-f0-9]{6}$/.test(basename(directory))) {
+    throw new Error("RUN_PATH_INVALID: run directory name must come from create-run.mjs");
+  }
+  return directory;
+}
+
+export function assertRunFile(candidate, runDirectory, expectedName = "") {
+  const filename = resolve(candidate);
+  const canonicalRunDirectory = assertCanonicalRunDirectory(runDirectory);
+  const sameDirectory = process.platform === "win32"
+    ? dirname(filename).toLowerCase() === canonicalRunDirectory.toLowerCase()
+    : dirname(filename) === canonicalRunDirectory;
+  if (!sameDirectory) {
+    throw new Error(`RUN_PATH_INVALID: ${filename} must be stored directly in ${canonicalRunDirectory}`);
+  }
+  const expectedFile = join(canonicalRunDirectory, expectedName);
+  const sameFile = process.platform === "win32"
+    ? filename.toLowerCase() === expectedFile.toLowerCase()
+    : filename === expectedFile;
+  if (expectedName && !sameFile) {
+    throw new Error(`RUN_PATH_INVALID: expected ${join(canonicalRunDirectory, expectedName)}`);
+  }
+  return filename;
+}
+
+export function pathsForRun(runId) {
+  if (!/^run-\d{14}-[a-f0-9]{6}$/.test(runId)) {
+    throw new Error("RUN_ID_INVALID: run_id must be generated by create-run.mjs");
+  }
+  const runDirectory = assertCanonicalRunDirectory(join(runsRoot, runId));
+  return {
+    project_root: projectRoot,
+    runs_root: runsRoot,
+    run_id: runId,
+    run_directory: runDirectory,
+    attachments_directory: join(runDirectory, "商品附件"),
+    images_directory: join(runDirectory, "成品图"),
+    vision_result: join(runDirectory, "vision-result.json"),
+    prompt_plan: join(runDirectory, "prompt-plan.json"),
+    asset_map: join(runDirectory, "reference-assets.json"),
+    jimeng_result: join(runDirectory, "jimeng-result.json"),
+  };
+}
+
+export function requestIndexDirectory(requestKey) {
+  const digest = createHash("sha256").update(String(requestKey)).digest("hex");
+  return join(requestIndexRoot, digest);
+}
+
+async function allocateRun() {
+  await mkdir(runsRoot, { recursive: true });
+  for (let attempt = 0; attempt < 5; attempt += 1) {
+    const paths = pathsForRun(createRunId());
+    try {
+      await mkdir(paths.run_directory, { recursive: false });
+      await mkdir(paths.attachments_directory);
+      await mkdir(paths.images_directory);
+      return paths;
+    } catch (error) {
+      if (error?.code !== "EEXIST") throw error;
+    }
+  }
+  throw new Error("RUN_CREATE_FAILED: could not allocate a unique run directory");
+}
+
+export async function createRun({ requestKey = "" } = {}) {
+  if (!requestKey) return allocateRun();
+  await mkdir(requestIndexRoot, { recursive: true });
+  const indexDirectory = requestIndexDirectory(requestKey);
+  try {
+    await mkdir(indexDirectory, { recursive: false });
+  } catch (error) {
+    if (error?.code !== "EEXIST") throw error;
+    for (let attempt = 0; attempt < 20; attempt += 1) {
+      try {
+        const indexed = JSON.parse(await readFile(join(indexDirectory, "run.json"), "utf8"));
+        const paths = pathsForRun(indexed.run_id);
+        return {
+          ...paths,
+          reused_existing_run: true,
+          request_key_hash: basename(indexDirectory),
+        };
+      } catch (readError) {
+        if (readError?.code !== "ENOENT") throw readError;
+        await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
+      }
+    }
+    throw new Error("RUN_REQUEST_LOCK_INCOMPLETE: an existing request lock has no run assignment");
+  }
+  try {
+    const paths = await allocateRun();
+    await writeFile(join(indexDirectory, "run.json"), `${JSON.stringify({
+      schema_version: "0.1",
+      run_id: paths.run_id,
+      created_at: new Date().toISOString(),
+    }, null, 2)}\n`, "utf8");
+    return {
+      ...paths,
+      reused_existing_run: false,
+      request_key_hash: basename(indexDirectory),
+    };
+  } catch (error) {
+    await rm(indexDirectory, { recursive: true, force: true });
+    throw error;
+  }
+}

+ 68 - 0
claude-code/fmode-image-set/mcp/src/core/run-paths.test.mjs

@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { rm } from "node:fs/promises";
+import { join } from "node:path";
+import test from "node:test";
+import {
+  assertCanonicalRunDirectory,
+  assertRunFile,
+  createRun,
+  createRunId,
+  pathsForRun,
+  requestIndexDirectory,
+  runsRoot,
+} from "./run-paths.mjs";
+
+test("run IDs and paths always resolve below the project run root", () => {
+  const runId = createRunId(new Date("2026-07-23T12:34:56.000Z"), "abcdef");
+  assert.equal(runId, "run-20260723203456-abcdef");
+  const paths = pathsForRun(runId);
+  assert.equal(paths.run_directory, join(runsRoot, runId));
+  assert.equal(paths.runs_root, join(paths.project_root, "生成结果", "电商七图"));
+  assert.equal(paths.attachments_directory, join(runsRoot, runId, "商品附件"));
+  assert.equal(paths.images_directory, join(runsRoot, runId, "成品图"));
+});
+
+test("run IDs use Asia/Shanghai calendar dates across the UTC midnight boundary", () => {
+  const runId = createRunId(new Date("2026-07-23T16:55:04.000Z"), "123abc");
+  assert.equal(runId, "run-20260724005504-123abc");
+});
+
+test("run path checks reject skill-source and nested output directories", () => {
+  assert.throws(
+    () => assertCanonicalRunDirectory(join(runsRoot, "run-ok", "nested")),
+    /RUN_PATH_INVALID/,
+  );
+  const runDirectory = join(runsRoot, "run-20260723123456-abcdef");
+  assert.throws(
+    () => assertRunFile(join(runDirectory, "nested", "jimeng-result.json"), runDirectory),
+    /RUN_PATH_INVALID/,
+  );
+});
+
+test("Windows run path checks ignore drive-letter casing", { skip: process.platform !== "win32" }, () => {
+  const runDirectory = join(runsRoot, "run-20260723123456-abcdef");
+  const lowerCaseDrive = `${runDirectory[0].toLowerCase()}${runDirectory.slice(1)}`;
+  assert.equal(assertCanonicalRunDirectory(lowerCaseDrive).toLowerCase(), runDirectory.toLowerCase());
+  assert.equal(
+    assertRunFile(join(lowerCaseDrive, "vision-result.json"), lowerCaseDrive, "vision-result.json").toLowerCase(),
+    join(runDirectory, "vision-result.json").toLowerCase(),
+  );
+});
+
+test("the same Claude request key always reuses one run directory", async () => {
+  const requestKey = `test-request-${randomUUID()}`;
+  const indexDirectory = requestIndexDirectory(requestKey);
+  let first;
+  try {
+    first = await createRun({ requestKey });
+    const second = await createRun({ requestKey });
+    assert.equal(first.reused_existing_run, false);
+    assert.equal(second.reused_existing_run, true);
+    assert.equal(second.run_id, first.run_id);
+    assert.equal(second.run_directory, first.run_directory);
+  } finally {
+    if (first) await rm(first.run_directory, { recursive: true, force: true });
+    await rm(indexDirectory, { recursive: true, force: true });
+  }
+});

+ 38 - 0
claude-code/fmode-image-set/mcp/src/core/run-state-integrity.mjs

@@ -0,0 +1,38 @@
+import { createHash, createHmac, timingSafeEqual } from "node:crypto";
+
+function unsignedState(state) {
+  const clone = { ...state };
+  delete clone.state_integrity;
+  return clone;
+}
+
+export function sha256(value) {
+  return createHash("sha256").update(value).digest("hex");
+}
+
+export function signState(state, secret) {
+  if (!secret) throw new Error("RUN_STATE_SECRET_MISSING");
+  const payload = JSON.stringify(unsignedState(state));
+  const signature = createHmac("sha256", secret).update(payload).digest("hex");
+  return {
+    ...unsignedState(state),
+    state_integrity: {
+      algorithm: "hmac-sha256",
+      signature,
+    },
+  };
+}
+
+export function verifyState(state, secret) {
+  const supplied = state?.state_integrity?.signature;
+  if (state?.state_integrity?.algorithm !== "hmac-sha256" || typeof supplied !== "string") {
+    throw new Error("RUN_STATE_UNSIGNED: executor state is missing its integrity signature");
+  }
+  const expected = signState(state, secret).state_integrity.signature;
+  const suppliedBuffer = Buffer.from(supplied, "hex");
+  const expectedBuffer = Buffer.from(expected, "hex");
+  if (suppliedBuffer.length !== expectedBuffer.length || !timingSafeEqual(suppliedBuffer, expectedBuffer)) {
+    throw new Error("RUN_STATE_TAMPERED: executor state was modified outside the executor");
+  }
+  return true;
+}

+ 14 - 0
claude-code/fmode-image-set/mcp/src/core/run-state-integrity.test.mjs

@@ -0,0 +1,14 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { signState, verifyState } from "./run-state-integrity.mjs";
+
+test("signed executor state verifies", () => {
+  const state = signState({ run_id: "run-1", results: [{ slot_id: "01", status: "complete" }] }, "secret");
+  assert.equal(verifyState(state, "secret"), true);
+});
+
+test("manual state edits are rejected", () => {
+  const state = signState({ run_id: "run-1", results: [{ slot_id: "01", status: "submission_unknown" }] }, "secret");
+  state.results[0].status = "not_submitted";
+  assert.throws(() => verifyState(state, "secret"), /RUN_STATE_TAMPERED/);
+});

+ 127 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.mjs

@@ -0,0 +1,127 @@
+import { spawn } from "node:child_process";
+import { readFile, readdir } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { inspectLatestClaudeInvocation } from "../../providers/claude-attachment-client.mjs";
+import { createRun, pathsForRun } from "../../core/run-paths.mjs";
+
+const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
+const productVisionFile = resolve(scriptsDirectory, "product-vision-client.mjs");
+const jimengClientFile = resolve(scriptsDirectory, "..", "..", "providers", "jimeng-v4-client.mjs");
+const args = process.argv.slice(2);
+const command = args[0];
+const option = (name, fallback = "") => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+
+function runNode(scriptFile, childArgs) {
+  return new Promise((resolvePromise, reject) => {
+    const child = spawn(process.execPath, [scriptFile, ...childArgs], {
+      cwd: process.cwd(),
+      env: process.env,
+      stdio: ["ignore", "pipe", "pipe"],
+    });
+    let stdout = "";
+    child.stdout.on("data", (chunk) => { stdout += chunk; });
+    child.stderr.on("data", (chunk) => { process.stderr.write(chunk); });
+    child.on("error", reject);
+    child.on("close", (code) => {
+      if (code !== 0) {
+        reject(new Error(`${scriptFile} exited with code ${code}`));
+        return;
+      }
+      resolvePromise(stdout);
+    });
+  });
+}
+
+export async function auditDelivery(paths, result) {
+  const tracked = new Set(
+    (result.results ?? [])
+      .flatMap((item) => item.local_images ?? [])
+      .map((file) => resolve(file).toLocaleLowerCase()),
+  );
+  const entries = await readdir(paths.images_directory, { withFileTypes: true });
+  const localFiles = entries
+    .filter((entry) => entry.isFile())
+    .map((entry) => resolve(join(paths.images_directory, entry.name)));
+  const untrackedFiles = localFiles.filter((file) => !tracked.has(file.toLocaleLowerCase()));
+  const missingFiles = [...tracked].filter((file) => !localFiles.some(
+    (candidate) => candidate.toLocaleLowerCase() === file,
+  ));
+  return {
+    status: untrackedFiles.length === 0 && missingFiles.length === 0 ? "verified" : "invalid",
+    untracked_files: untrackedFiles,
+    missing_files: missingFiles,
+  };
+}
+
+async function withDeliveryAudit(paths, summary) {
+  const state = JSON.parse(await readFile(paths.jimeng_result, "utf8"));
+  const deliveryIntegrity = await auditDelivery(paths, state);
+  if (deliveryIntegrity.status === "verified") {
+    return { ...summary, delivery_integrity: deliveryIntegrity };
+  }
+  return {
+    ...summary,
+    status: "blocked",
+    delivery_integrity: deliveryIntegrity,
+    next_action: "stop_and_report_delivery_integrity_error",
+  };
+}
+
+async function runCli() {
+if (command === "start") {
+  const explicitImage = option("--image");
+  const invocation = await inspectLatestClaudeInvocation({
+    cwd: option("--cwd", process.cwd()),
+    claudeConfigDirectory: option("--claude-config-dir", process.env.CLAUDE_CONFIG_DIR),
+    sessionId: option("--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+  });
+  if (!explicitImage && invocation.image_count !== 1) {
+    throw new Error(`CURRENT_REQUEST_IMAGE_COUNT_INVALID: expected one image, found ${invocation.image_count}`);
+  }
+  const paths = await createRun({ requestKey: `${invocation.session_id}:${invocation.prompt_id}` });
+  const visionStdout = await runNode(productVisionFile, explicitImage
+    ? ["--image", explicitImage, "--output", paths.vision_result]
+    : ["--from-claude-attachment", "--output", paths.vision_result]);
+  const vision = JSON.parse(visionStdout);
+  console.log(JSON.stringify({
+    status: vision.status === "complete" ? "ready_for_plan" : "blocked",
+    paths,
+    vision,
+    next_action: vision.status === "complete"
+      ? "write_prompt_plan_then_execute_same_run"
+      : "stop_and_report_error",
+  }, null, 2));
+} else if (command === "execute" || command === "resume") {
+  const runId = option("--run-id");
+  if (!runId) throw new Error("RUN_ID_REQUIRED");
+  const paths = pathsForRun(runId);
+  const maxCost = option("--max-cost", "1.54");
+  const stdout = await runNode(jimengClientFile, [
+    paths.prompt_plan,
+    "--execute",
+    "--vision-result", paths.vision_result,
+    "--asset-map", paths.asset_map,
+    "--max-cost", maxCost,
+    "--output", paths.jimeng_result,
+  ]);
+  const summary = JSON.parse(stdout);
+  console.log(JSON.stringify(await withDeliveryAudit(paths, summary), null, 2));
+} else if (command === "status") {
+  const runId = option("--run-id");
+  if (!runId) throw new Error("RUN_ID_REQUIRED");
+  const paths = pathsForRun(runId);
+  const result = JSON.parse(await readFile(paths.jimeng_result, "utf8"));
+  console.log(JSON.stringify(await withDeliveryAudit(paths, { paths, result }), null, 2));
+} else {
+  throw new Error("Usage: node image-set-runner.mjs start [--image <path-or-url>] | execute --run-id <run_id> [--max-cost 1.54] | resume --run-id <run_id> | status --run-id <run_id>");
+}
+}
+
+const invokedFile = process.argv[1] ? resolve(process.argv[1]) : "";
+if (invokedFile === fileURLToPath(import.meta.url)) {
+  await runCli();
+}

+ 24 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/image-set-runner.test.mjs

@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import { rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import test from "node:test";
+import { auditDelivery } from "./image-set-runner.mjs";
+import { createRun } from "../../core/run-paths.mjs";
+
+test("delivery audit rejects files copied from another run", async () => {
+  const paths = await createRun();
+  try {
+    const trackedFile = join(paths.images_directory, "01-白底主图.png");
+    const copiedFile = join(paths.images_directory, "06-场景图.png");
+    await writeFile(trackedFile, "tracked");
+    await writeFile(copiedFile, "copied");
+    const audit = await auditDelivery(paths, {
+      results: [{ slot_id: "01", status: "complete", local_images: [trackedFile] }],
+    });
+    assert.equal(audit.status, "invalid");
+    assert.deepEqual(audit.untracked_files, [copiedFile]);
+    assert.deepEqual(audit.missing_files, []);
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});

+ 394 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/product-vision-client.mjs

@@ -0,0 +1,394 @@
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { dirname, isAbsolute, join, resolve } from "node:path";
+import { extractLatestClaudeAttachment } from "../../providers/claude-attachment-client.mjs";
+import { uploadLocalImage } from "../../providers/qiniu-upload-client.mjs";
+import { assertRunFile } from "../../core/run-paths.mjs";
+import {
+  compileProductLock,
+  loadVisionConfig,
+  requestVisionJson,
+} from "../../providers/commerce-vision-provider.mjs";
+
+const args = process.argv.slice(2);
+const option = (name, fallback = "") => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+const imageArg = option("--image");
+const outputArg = option("--output");
+const fromClaudeAttachment = args.includes("--from-claude-attachment");
+if ((!imageArg && !fromClaudeAttachment) || (imageArg && fromClaudeAttachment) || !outputArg) {
+  throw new Error("Usage: node product-vision-client.mjs (--image <local-path-or-url> | --from-claude-attachment) --output <vision-result.json> [--dry-run]");
+}
+
+const outputFile = assertRunFile(resolve(outputArg), dirname(resolve(outputArg)), "vision-result.json");
+const publicUrlPattern = /^https?:\/\//i;
+
+async function writeResult(result) {
+  await mkdir(dirname(outputFile), { recursive: true });
+  await writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`, "utf8");
+  console.log(JSON.stringify(result, null, 2));
+}
+
+class GracefulStop extends Error {}
+
+async function fail(code, message, visionCalled = false, stage = "") {
+  await writeResult({
+    schema_version: "0.3",
+    status: "blocked",
+    vision_called: visionCalled,
+    jimeng_called: false,
+    retryable: false,
+    next_action: "stop_and_report_error",
+    error: { code, message, ...(stage ? { stage } : {}) },
+  });
+  throw new GracefulStop();
+}
+
+async function resolveImage(value) {
+  if (publicUrlPattern.test(value)) return { kind: "public", source: value, public_url: value };
+  const localPath = isAbsolute(value) ? resolve(value) : resolve(process.cwd(), value);
+  try {
+    if (!(await stat(localPath)).isFile()) throw new Error("not a file");
+  } catch {
+    await fail("REFERENCE_IMAGE_NOT_FOUND", `The user-provided product image does not exist: ${localPath}`);
+  }
+  if (args.includes("--dry-run")) return { kind: "local", source: localPath, public_url: null };
+  const uploaded = await uploadLocalImage(localPath);
+  return { kind: "local", source: localPath, public_url: uploaded.url, asset: uploaded };
+}
+
+const text = (input) => typeof input === "string" ? input.trim() : "";
+const strings = (input) => Array.isArray(input) ? input.map(text).filter(Boolean) : [];
+const confidence = (input) => ["high", "medium", "low", "unknown"].includes(input) ? input : "unknown";
+const uniqueStrings = (input) => [...new Set(strings(input))];
+
+function semanticKey(value) {
+  return text(value)
+    .toLocaleLowerCase()
+    .replace(/[,。、“”‘’;:,.!!??++/\\|()[\]{}<>《》\s_-]/g, "")
+    .replace(/搭配|配有|配|以及|及|与|和/g, "");
+}
+
+function bigrams(value) {
+  const normalized = semanticKey(value);
+  if (normalized.length < 2) return new Set([normalized]);
+  return new Set([...normalized.slice(0, -1)].map((character, index) => `${character}${normalized[index + 1]}`));
+}
+
+function textSimilarity(left, right) {
+  const a = semanticKey(left);
+  const b = semanticKey(right);
+  if (!a || !b) return 0;
+  if (a.includes(b) || b.includes(a)) return Math.min(a.length, b.length) / Math.max(a.length, b.length);
+  const aPairs = bigrams(a);
+  const bPairs = bigrams(b);
+  const intersection = [...aPairs].filter((item) => bPairs.has(item)).length;
+  const union = new Set([...aPairs, ...bPairs]).size;
+  return union ? intersection / union : 0;
+}
+
+function semanticUniqueStrings(input) {
+  const result = [];
+  for (const value of strings(input)) {
+    if (result.some((existing) => textSimilarity(existing, value) >= 0.48)) continue;
+    result.push(value);
+  }
+  return result;
+}
+
+function normalizeFactReference(value) {
+  const match = text(value).match(/visible[\s_-]*fact\s*[:#_-]?\s*(\d+)/i);
+  return match ? `visible_fact:${Number(match[1])}` : "";
+}
+
+function inferFactReferences(value, factValues, fallbackIndex) {
+  const ranked = factValues
+    .map((fact, index) => ({ reference: `visible_fact:${index + 1}`, score: textSimilarity(value, fact) }))
+    .sort((left, right) => right.score - left.score);
+  const matched = ranked.filter((item) => item.score >= 0.12).slice(0, 2).map((item) => item.reference);
+  if (matched.length > 0) return matched;
+  return factValues.length > 0 ? [`visible_fact:${Math.min(fallbackIndex + 1, factValues.length)}`] : [];
+}
+
+function normalizeSellingPoints(value, factReferences, { fallbackValues = [], factValues = [] } = {}) {
+  const validReferences = [...factReferences];
+  const parsedItems = Array.isArray(value) ? value.map((item, index) => typeof item === "string" ? {
+    id: `visible-sp-${index + 1}`,
+    value: text(item),
+    basis_refs: [],
+    confidence: "unknown",
+  } : {
+    id: text(item?.id) || `visible-sp-${index + 1}`,
+    value: text(item?.value),
+    basis_refs: strings(item?.basis_refs)
+      .map(normalizeFactReference)
+      .filter((reference) => factReferences.has(reference)),
+    confidence: confidence(item?.confidence),
+  }).filter((item) => item.value) : [];
+  const items = [];
+  const seenValues = new Set();
+  for (const item of parsedItems) {
+    const key = item.value.toLocaleLowerCase();
+    if (seenValues.has(key)) continue;
+    seenValues.add(key);
+    items.push(item);
+  }
+  for (const fallbackValue of uniqueStrings(fallbackValues)) {
+    const key = fallbackValue.toLocaleLowerCase();
+    if (items.length >= 2) break;
+    if (seenValues.has(key)) continue;
+    seenValues.add(key);
+    items.push({
+      id: "",
+      value: fallbackValue,
+      basis_refs: [],
+      confidence: "medium",
+    });
+  }
+  if (items.length < 2 && validReferences.length > 0) {
+    items.push({
+      id: "",
+      value: "商品主体外观特征清晰可辨",
+      basis_refs: [],
+      confidence: "medium",
+    });
+  }
+  return items.slice(0, 4).map((item, index) => ({
+    ...item,
+    id: `visible-sp-${index + 1}`,
+    basis_refs: factValues.length > 0
+      ? inferFactReferences(item.value, factValues, index)
+      : item.basis_refs.length > 0 || validReferences.length === 0
+        ? item.basis_refs
+        : [validReferences[Math.min(index, validReferences.length - 1)]],
+  }));
+}
+
+function normalizeDraft(value) {
+  const visibleFacts = strings(value.visible_facts);
+  if (visibleFacts.length === 0) throw new Error("Vision draft requires visible_facts");
+  const factReferences = new Set(visibleFacts.map((_, index) => `visible_fact:${index + 1}`));
+  const productName = typeof value.product_name === "string" ? {
+    value: text(value.product_name),
+    basis: "image",
+    confidence: "unknown",
+  } : {
+    value: text(value.product_name?.value),
+    basis: text(value.product_name?.basis) || "image",
+    confidence: confidence(value.product_name?.confidence),
+  };
+  const visibleText = Array.isArray(value.visible_text) ? value.visible_text.map((item) => typeof item === "string" ? {
+    text: text(item),
+    confidence: "unknown",
+  } : {
+    text: text(item?.text),
+    confidence: confidence(item?.confidence),
+  }).filter((item) => item.text) : [];
+  const visibleSpecs = value.visible_specs && typeof value.visible_specs === "object" && !Array.isArray(value.visible_specs)
+    ? Object.fromEntries(Object.entries(value.visible_specs).map(([key, item]) => [key, text(item)]).filter(([, item]) => item))
+    : {};
+  return {
+    category: text(value.category) || "unknown",
+    product_name: { ...productName, value: productName.value || text(value.category) || "unknown" },
+    visible_facts: visibleFacts,
+    visible_text: visibleText,
+    visible_selling_points: normalizeSellingPoints(value.visible_selling_points, factReferences, {
+      fallbackValues: visibleFacts,
+      factValues: visibleFacts,
+    }),
+    visible_specs: visibleSpecs,
+    creative_positioning: strings(value.creative_positioning),
+    suggested_audiences: strings(value.suggested_audiences),
+    suggested_scenes: strings(value.suggested_scenes),
+    unsupported_claims: strings(value.unsupported_claims),
+    product_invariants: strings(value.product_invariants),
+    material_and_light_behavior: strings(value.material_and_light_behavior),
+    uncertain_features: strings(value.uncertain_features),
+    generation_risks: strings(value.generation_risks),
+  };
+}
+
+function normalizeAudit(value, draft) {
+  const auditedFacts = uniqueStrings(value.verified_visible_facts);
+  const verifiedFacts = auditedFacts.length > 0 ? auditedFacts : uniqueStrings(draft.visible_facts);
+  if (verifiedFacts.length === 0) throw new Error("Vision pipeline could not identify any visible product facts");
+  const factReferences = new Set(verifiedFacts.map((_, index) => `visible_fact:${index + 1}`));
+  const rawManifest = value.identity_manifest ?? {};
+  const mustPreserve = semanticUniqueStrings([
+    ...strings(rawManifest.must_preserve),
+    ...strings(draft.product_invariants),
+    ...verifiedFacts,
+  ]).slice(0, 7);
+  for (const fallback of ["商品整体轮廓与参考图一致", "商品主体数量与参考图一致"]) {
+    if (mustPreserve.length >= 3) break;
+    if (!mustPreserve.includes(fallback)) mustPreserve.push(fallback);
+  }
+  const identityManifest = {
+    must_preserve: mustPreserve,
+    must_not_add: uniqueStrings(rawManifest.must_not_add),
+    unknown_do_not_assert: uniqueStrings(rawManifest.unknown_do_not_assert),
+  };
+  identityManifest.product_lock = compileProductLock(identityManifest);
+  const productName = typeof value.product_name === "string" ? {
+    value: text(value.product_name),
+    basis: "verified visible evidence",
+    confidence: "medium",
+  } : {
+    value: text(value.product_name?.value) || draft.product_name.value,
+    basis: text(value.product_name?.basis) || "verified visible evidence",
+    confidence: confidence(value.product_name?.confidence),
+  };
+  const visibleSpecs = value.verified_specs && typeof value.verified_specs === "object" && !Array.isArray(value.verified_specs)
+    ? Object.fromEntries(Object.entries(value.verified_specs).map(([key, item]) => [key, text(item)]).filter(([, item]) => item))
+    : {};
+  const unknownFeatures = strings(value.unknown_features);
+  const auditedSellingPoints = normalizeSellingPoints(value.verified_selling_points, factReferences, {
+    fallbackValues: [
+      ...draft.visible_selling_points.map((item) => item.value),
+      ...verifiedFacts,
+      ...identityManifest.must_preserve,
+    ],
+    factValues: verifiedFacts,
+  });
+  return {
+    ...draft,
+    category: text(value.category) || draft.category,
+    product_name: productName,
+    visible_facts: verifiedFacts,
+    verified_visible_facts: verifiedFacts,
+    inferred_attributes: strings(value.inferred_attributes),
+    unknown_features: unknownFeatures,
+    visible_selling_points: auditedSellingPoints,
+    visible_specs: visibleSpecs,
+    product_invariants: identityManifest.must_preserve,
+    uncertain_features: unknownFeatures,
+    identity_manifest: identityManifest,
+    fact_audit: {
+      status: "verified",
+      corrections: strings(value.corrections),
+    },
+  };
+}
+
+async function main() {
+if (!args.includes("--dry-run")) {
+  try {
+    const existing = JSON.parse((await readFile(outputFile, "utf8")).replace(/^\uFEFF/, ""));
+    if (["complete", "blocked"].includes(existing.status)) {
+      console.log(JSON.stringify({
+        ...existing,
+        reused_existing_result: true,
+        external_calls_made: false,
+      }, null, 2));
+      throw new GracefulStop();
+    }
+  } catch (error) {
+    if (error instanceof GracefulStop) throw error;
+    if (error?.code !== "ENOENT") {
+      await fail("VISION_RESULT_STATE_INVALID", `Cannot safely reuse the existing vision result: ${error.message}`);
+    }
+  }
+}
+
+let effectiveImage = imageArg;
+let attachment = null;
+if (fromClaudeAttachment) {
+  try {
+    const maxAgeMinutes = Number(option("--attachment-max-age-minutes", "30"));
+    attachment = await extractLatestClaudeAttachment({
+      cwd: option("--attachment-cwd", process.cwd()),
+      outputDirectory: join(dirname(outputFile), "商品附件"),
+      claudeConfigDirectory: option("--claude-config-dir", process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")),
+      sessionId: option("--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+      maxAgeMs: Number.isFinite(maxAgeMinutes) && maxAgeMinutes > 0 ? maxAgeMinutes * 60 * 1000 : 30 * 60 * 1000,
+    });
+    effectiveImage = attachment.file_path;
+  } catch (error) {
+    await fail(error?.code || "CLAUDE_ATTACHMENT_RESOLUTION_FAILED", error?.message || "Claude attachment resolution failed");
+  }
+}
+
+const reference = await resolveImage(effectiveImage);
+if (attachment) {
+  reference.attachment = {
+    source: attachment.source,
+    media_type: attachment.media_type,
+    sha256: attachment.sha256,
+    prompt_id: attachment.prompt_id,
+    session_id: attachment.session_id,
+    captured_at: attachment.captured_at,
+  };
+}
+const config = await loadVisionConfig();
+if (args.includes("--dry-run")) {
+  await writeResult({
+    schema_version: "0.3",
+    status: "dry-run",
+    vision_called: false,
+    reference,
+    provider: { name: config.provider, model: config.model },
+  });
+  throw new GracefulStop();
+}
+if (!config.apiKey) await fail("VISION_PROVIDER_NOT_CONFIGURED", "Qwen vision API key is not configured on this machine");
+if (!reference.public_url) await fail("REFERENCE_UPLOAD_FAILED", "The product image could not be converted to a public URL");
+
+const draftInstruction = `你是跨品类电商商品视觉分析器。只依据输入商品图片提取可见事实。不得猜测手感、气味、容量、真实尺寸、材质成分、性能、功效或图片中不可见的信息。只返回 JSON,不要 Markdown。结构:
+{"category":"品类","product_name":{"value":"名称","basis":"依据","confidence":"high|medium|low"},"visible_facts":["可见事实"],"visible_text":[{"text":"可见文字","confidence":"high|medium|low"}],"visible_selling_points":[{"id":"visible-sp-1","value":"视觉卖点","basis_refs":["visible_fact:1"],"confidence":"high|medium|low"},{"id":"visible-sp-2","value":"另一视觉卖点","basis_refs":["visible_fact:2"],"confidence":"high|medium|low"}],"visible_specs":{"可见规格":"值"},"creative_positioning":["创意方向"],"suggested_audiences":["创意人群假设"],"suggested_scenes":["创意场景"],"unsupported_claims":["图片无法证明的结论"],"product_invariants":["不得改变的身份特征"],"material_and_light_behavior":["材质布光表现"],"uncertain_features":["无法确认内容"],"generation_risks":["容易生成错误的结构"]}`;
+
+let validationStage = "draft_request";
+try {
+  let draftCall;
+  try {
+    draftCall = await requestVisionJson({
+      config,
+      imageUrls: [reference.public_url],
+      instruction: draftInstruction,
+      temperature: 0.1,
+    });
+  } catch (error) {
+    await fail("VISION_REQUEST_FAILED", error.message || "Qwen vision request failed", true, "draft_request");
+  }
+  validationStage = "draft_normalization";
+  const draft = normalizeDraft(draftCall.value);
+  validationStage = "fact_audit_request";
+  const auditInstruction = `你是商品视觉事实审计器。请对照同一张商品参考图审计下方初稿,纠正把推断当事实、错误结构术语和前后矛盾。只返回 JSON,不要 Markdown。
+初稿:${JSON.stringify(draft)}
+返回:{"category":"中性品类","product_name":{"value":"只含可验证品牌/系列/品类","basis":"verified visible evidence","confidence":"high|medium|low"},"verified_visible_facts":["直接可见事实"],"inferred_attributes":["合理但不能直接证实的属性"],"unknown_features":["未展示或不能确定的属性"],"verified_selling_points":[{"id":"visible-sp-1","value":"可见视觉卖点","basis_refs":["visible_fact:1"],"confidence":"high|medium|low"},{"id":"visible-sp-2","value":"另一可见视觉卖点","basis_refs":["visible_fact:2"],"confidence":"high|medium|low"}],"verified_specs":{"直接可见规格":"值"},"identity_manifest":{"must_preserve":["至少3项商品身份特征"],"must_not_add":["参考图不存在且不得新增的结构"],"unknown_do_not_assert":["不能确定且提示词不得断言的属性"]},"corrections":["相对初稿的修正"]}
+规则:材质成分、真实尺寸、容量、内部结构、未显示闭合方式不得列为可见事实;正常敞开的衣襟不是前中开衩;没有可见纽扣不得写单排扣或双排扣;领型必须按实际轮廓;卖点不得加入气味、触感、性能或功效。`;
+  let auditCall;
+  try {
+    auditCall = await requestVisionJson({
+      config,
+      imageUrls: [reference.public_url],
+      instruction: auditInstruction,
+      temperature: 0,
+    });
+  } catch (error) {
+    await fail("VISION_FACT_AUDIT_FAILED", error.message || "Qwen vision fact audit failed", true, "fact_audit_request");
+  }
+  validationStage = "fact_audit_normalization";
+  const productUnderstanding = normalizeAudit(auditCall.value, draft);
+  await writeResult({
+    schema_version: "0.3",
+    status: "complete",
+    vision_called: true,
+    fact_audit_called: true,
+    reference,
+    provider: { name: config.provider, model: config.model },
+    product_understanding: productUnderstanding,
+    usage: { draft: draftCall.usage ?? null, audit: auditCall.usage ?? null },
+  });
+} catch (error) {
+  if (error instanceof GracefulStop) throw error;
+  await fail("VISION_RESPONSE_INVALID", error.message, true, validationStage);
+}
+}
+
+try {
+  await main();
+} catch (error) {
+  if (!(error instanceof GracefulStop)) throw error;
+}

+ 523 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.mjs

@@ -0,0 +1,523 @@
+import { readFile } from "node:fs/promises";
+
+const filename = process.argv[2];
+if (!filename) throw new Error("Usage: node validate-plan.mjs <prompt-plan.json>");
+
+const plan = JSON.parse(await readFile(filename, "utf8"));
+const errors = [];
+const isText = (value) => typeof value === "string" && value.trim().length > 0;
+const asArray = (value) => Array.isArray(value) ? value : [];
+const countValues = (values) => new Map(values.map((value) => [
+  value,
+  values.filter((item) => item === value).length,
+]));
+
+if (plan.schema_version !== "0.6") errors.push("schema_version must be 0.6");
+if (!isText(plan.run_id)) errors.push("run_id is required");
+if (!new Set(["ready", "needs_input"]).has(plan.status)) errors.push("status must be ready or needs_input");
+if (plan.user_output_mode !== "images_only") errors.push("user_output_mode must be images_only");
+if (!isText(plan.request?.instruction)) errors.push("request.instruction is required");
+if (!Array.isArray(plan.request?.reference_images)) errors.push("request.reference_images must be an array");
+if (!isText(plan.context?.platform)) errors.push("context.platform is required");
+if (!new Set(["available", "unavailable"]).has(plan.context?.voc?.status)) errors.push("context.voc.status is invalid");
+if (!Array.isArray(plan.context?.voc?.findings)) errors.push("context.voc.findings must be an array");
+
+const understanding = plan.product_understanding ?? {};
+if (!isText(understanding.category)) errors.push("product_understanding.category is required");
+if (!isText(understanding.product_name?.value)) errors.push("product_understanding.product_name.value is required");
+if (!isText(understanding.product_name?.basis)) errors.push("product_understanding.product_name.basis is required");
+if (!new Set(["high", "medium", "low", "unknown"]).has(understanding.product_name?.confidence)) {
+  errors.push("product_understanding.product_name.confidence is invalid");
+}
+for (const key of [
+  "visible_facts",
+  "verified_visible_facts",
+  "inferred_attributes",
+  "unknown_features",
+  "provided_facts",
+  "candidate_selling_points",
+  "creative_positioning",
+  "suggested_audiences",
+  "suggested_scenes",
+  "unsupported_claims",
+  "unknowns",
+]) {
+  if (!Array.isArray(understanding[key])) errors.push(`product_understanding.${key} must be an array`);
+}
+if (!understanding.visible_specs || typeof understanding.visible_specs !== "object" || Array.isArray(understanding.visible_specs)) {
+  errors.push("product_understanding.visible_specs must be an object");
+}
+const identityManifest = understanding.identity_manifest ?? {};
+for (const key of ["must_preserve", "must_not_add", "unknown_do_not_assert"]) {
+  if (!Array.isArray(identityManifest[key])) errors.push(`product_understanding.identity_manifest.${key} must be an array`);
+}
+if (asArray(identityManifest.must_preserve).length < 3) {
+  errors.push("product_understanding.identity_manifest.must_preserve requires at least three features");
+}
+if (!isText(identityManifest.product_lock)) {
+  errors.push("product_understanding.identity_manifest.product_lock is required");
+} else {
+  const identityLockLength = [...identityManifest.product_lock].length;
+  if (identityLockLength < 20 || identityLockLength > 160) {
+    errors.push(`product_understanding.identity_manifest.product_lock must be 20..160 characters (${identityLockLength})`);
+  }
+}
+
+const sellingPoints = asArray(understanding.candidate_selling_points);
+const validSellingPointBasisRefs = new Set(["user_instruction"]);
+asArray(understanding.visible_facts).forEach((_, index) => validSellingPointBasisRefs.add(`visible_fact:${index + 1}`));
+asArray(understanding.provided_facts).forEach((_, index) => validSellingPointBasisRefs.add(`provided_fact:${index + 1}`));
+asArray(plan.context?.voc?.findings).forEach((_, index) => validSellingPointBasisRefs.add(`voc:${index + 1}`));
+if (plan.status === "ready" && sellingPoints.length < 2) {
+  errors.push("ready plan requires at least two candidate_selling_points");
+}
+const sellingPointIds = [];
+for (const [index, point] of sellingPoints.entries()) {
+  if (!isText(point?.id)) errors.push(`candidate_selling_points[${index}].id is required`);
+  if (!isText(point?.value)) errors.push(`candidate_selling_points[${index}].value is required`);
+  if (!Array.isArray(point?.basis_refs) || point.basis_refs.length === 0) {
+    errors.push(`candidate_selling_points[${index}].basis_refs must be non-empty`);
+  } else {
+    for (const reference of point.basis_refs) {
+      if (!validSellingPointBasisRefs.has(reference)) {
+        errors.push(`candidate_selling_points[${index}].basis_refs contains unknown reference: ${reference}`);
+      }
+    }
+  }
+  if (!new Set(["high", "medium", "low", "unknown"]).has(point?.confidence)) {
+    errors.push(`candidate_selling_points[${index}].confidence is invalid`);
+  }
+  sellingPointIds.push(point?.id);
+}
+if (new Set(sellingPointIds).size !== sellingPointIds.length) {
+  errors.push("candidate_selling_points ids must be unique");
+}
+
+for (const key of ["product_invariants", "visual_system", "prohibited_changes"]) {
+  if (!Array.isArray(plan.global_consistency_lock?.[key])) errors.push(`global_consistency_lock.${key} must be an array`);
+}
+
+const requiredAllocation = { hero: 1, scene: 2, selling_point: 2, decision_support: 2 };
+for (const [key, value] of Object.entries(requiredAllocation)) {
+  if (plan.set_blueprint?.business_allocation?.[key] !== value) {
+    errors.push(`set_blueprint.business_allocation.${key} must be ${value}`);
+  }
+}
+if (!isText(plan.set_blueprint?.visual_thesis)) errors.push("set_blueprint.visual_thesis is required");
+for (const key of ["narrative_arc", "shared_visual_dna", "intentional_variations"]) {
+  if (!Array.isArray(plan.set_blueprint?.[key]) || plan.set_blueprint[key].length === 0) {
+    errors.push(`set_blueprint.${key} must be a non-empty array`);
+  }
+}
+
+if (!Array.isArray(plan.images)) errors.push("images must be an array");
+if (plan.status === "ready" && plan.images?.length !== 7) errors.push("ready plan must contain exactly seven images");
+
+const businessCategories = new Set(Object.keys(requiredAllocation));
+const renderFamiliesByCategory = {
+  hero: new Set(["isolated_product"]),
+  scene: new Set(["environmental_scene", "human_interaction", "usage_demonstration"]),
+  selling_point: new Set(["material_demonstration", "feature_demonstration", "annotated_detail"]),
+  decision_support: new Set([
+    "multi_angle",
+    "usage_sequence",
+    "package_contents",
+    "scale_context",
+    "comparison_sequence",
+    "structured_information",
+  ]),
+};
+const sceneComplexities = new Set(["simple", "medium", "complex"]);
+const moduleNames = [
+  "product_lock",
+  "image_job",
+  "scene",
+  "composition",
+  "lighting_material",
+  "camera_style",
+  "text_layout",
+  "negative_constraints",
+];
+const complexityRules = {
+  simple: { promptMin: 100, sceneMin: 12 },
+  medium: { promptMin: 220, sceneMin: 60 },
+  complex: { promptMin: 360, sceneMin: 160 },
+};
+const highRiskClaimTerms = [
+  "防水", "抗菌", "临床", "认证", "销量第一", "最畅销", "耐磨", "耐用", "续航",
+  "兼容", "承重", "保质期", "无毒", "不刺激", "留香", "香调", "成分", "浓度", "功效",
+];
+const evidenceText = JSON.stringify([
+  plan.request?.instruction,
+  ...asArray(understanding.provided_facts),
+  ...sellingPoints.map((item) => item.value),
+  understanding.visible_specs ?? {},
+  ...asArray(plan.context?.voc?.findings),
+]);
+const knownEvidenceRefs = new Set(["user_instruction"]);
+asArray(understanding.visible_facts).forEach((_, index) => knownEvidenceRefs.add(`visible_fact:${index + 1}`));
+asArray(understanding.provided_facts).forEach((_, index) => knownEvidenceRefs.add(`provided_fact:${index + 1}`));
+asArray(understanding.creative_positioning).forEach((_, index) => knownEvidenceRefs.add(`creative_positioning:${index + 1}`));
+asArray(understanding.suggested_audiences).forEach((_, index) => knownEvidenceRefs.add(`suggested_audience:${index + 1}`));
+asArray(understanding.suggested_scenes).forEach((_, index) => knownEvidenceRefs.add(`suggested_scene:${index + 1}`));
+asArray(plan.context?.voc?.findings).forEach((_, index) => knownEvidenceRefs.add(`voc:${index + 1}`));
+Object.keys(understanding.visible_specs ?? {}).forEach((key) => knownEvidenceRefs.add(`visible_spec:${key}`));
+sellingPointIds.filter(isText).forEach((id) => knownEvidenceRefs.add(id));
+
+const ids = [];
+const decisions = [];
+const shotSpecs = [];
+const setDesigns = [];
+const creativeBodies = [];
+const categoryValues = [];
+const sellingImages = [];
+const categoryRenderFamilies = new Map();
+const staticProductFamilies = new Set(["isolated_product", "material_demonstration", "annotated_detail"]);
+let staticProductCount = 0;
+const requiredShotText = [
+  "narrative_job",
+  "composition_family",
+  "shot_scale",
+  "camera_angle",
+  "product_placement",
+  "depth_structure",
+  "background_family",
+  "lighting_family",
+  "interaction_mode",
+  "information_density",
+];
+const cameraAxes = new Set(["front", "three_quarter_left", "three_quarter_right", "side", "top", "low", "macro"]);
+const framingModes = new Set(["full_product", "product_in_context", "detail", "multi_panel", "human_action"]);
+const productPoses = new Set(["upright", "laid_flat", "held", "in_use", "multi_view", "detail_crop", "arranged_sequence"]);
+
+for (const [index, image] of asArray(plan.images).entries()) {
+  ids.push(image.id);
+  decisions.push(image.decision_subject);
+  categoryValues.push(image.business_category);
+  if (!isText(image.id)) errors.push(`images[${index}].id is required`);
+  if (!isText(image.role)) errors.push(`images[${index}].role is required`);
+  if (!businessCategories.has(image.business_category)) {
+    errors.push(`images[${index}].business_category is invalid`);
+  }
+  if (!isText(image.decision_subject)) errors.push(`images[${index}].decision_subject is required`);
+  if (!Array.isArray(image.evidence_refs) || image.evidence_refs.length === 0) {
+    errors.push(`images[${index}].evidence_refs must be non-empty`);
+  } else {
+    for (const reference of image.evidence_refs) {
+      if (!knownEvidenceRefs.has(reference)) {
+        errors.push(`images[${index}].evidence_refs contains unknown reference: ${reference}`);
+      }
+    }
+  }
+  if (typeof image.selling_point_id !== "string") errors.push(`images[${index}].selling_point_id must be a string`);
+  if (!isText(image.purpose)) errors.push(`images[${index}].purpose is required`);
+  if (!Array.isArray(image.voc_refs)) errors.push(`images[${index}].voc_refs must be an array`);
+
+  const setDesign = image.set_design ?? {};
+  setDesigns.push(setDesign);
+  const allowedRenderFamilies = renderFamiliesByCategory[image.business_category];
+  if (!allowedRenderFamilies?.has(setDesign.render_family)) {
+    errors.push(`images[${index}].set_design.render_family is invalid for ${image.business_category ?? "unknown"}`);
+  }
+  if (setDesign.render_family === "structured_information") {
+    errors.push(`images[${index}] structured_information is unavailable until a deterministic text compositor is implemented`);
+  }
+  if (staticProductFamilies.has(setDesign.render_family)) staticProductCount += 1;
+  if (businessCategories.has(image.business_category) && isText(setDesign.render_family)) {
+    const values = categoryRenderFamilies.get(image.business_category) ?? [];
+    values.push(setDesign.render_family);
+    categoryRenderFamilies.set(image.business_category, values);
+  }
+  if (!sceneComplexities.has(setDesign.scene_complexity)) {
+    errors.push(`images[${index}].set_design.scene_complexity is invalid`);
+  }
+  if (!isText(setDesign.unique_visual_hook)) errors.push(`images[${index}].set_design.unique_visual_hook is required`);
+  if (!isText(setDesign.contrast_with_previous)) errors.push(`images[${index}].set_design.contrast_with_previous is required`);
+
+  if (image.business_category === "selling_point") {
+    sellingImages.push(image);
+    if (!sellingPointIds.includes(image.selling_point_id)) {
+      errors.push(`images[${index}].selling_point_id must match a candidate_selling_points id`);
+    }
+    if (!image.evidence_refs?.includes(image.selling_point_id)) {
+      errors.push(`images[${index}].evidence_refs must include its selling_point_id`);
+    }
+  } else if (isText(image.selling_point_id)) {
+    errors.push(`images[${index}].selling_point_id is only allowed for selling_point images`);
+  }
+
+  for (const key of requiredShotText) {
+    if (!isText(image.shot_spec?.[key])) errors.push(`images[${index}].shot_spec.${key} is required`);
+  }
+  if (!cameraAxes.has(image.shot_spec?.camera_axis)) {
+    errors.push(`images[${index}].shot_spec.camera_axis is invalid`);
+  }
+  if (!framingModes.has(image.shot_spec?.framing_mode)) {
+    errors.push(`images[${index}].shot_spec.framing_mode is invalid`);
+  }
+  if (!productPoses.has(image.shot_spec?.product_pose)) {
+    errors.push(`images[${index}].shot_spec.product_pose is invalid`);
+  }
+  const occupancy = image.shot_spec?.product_occupancy_pct;
+  if (!(Number.isFinite(occupancy) && occupancy >= 10 && occupancy <= 95)) {
+    errors.push(`images[${index}].shot_spec.product_occupancy_pct must be 10..95`);
+  }
+  shotSpecs.push(image.shot_spec ?? {});
+
+  const promptModules = image.prompt_modules ?? {};
+  for (const moduleName of moduleNames) {
+    if (typeof promptModules[moduleName] !== "string") {
+      errors.push(`images[${index}].prompt_modules.${moduleName} must be a string`);
+    }
+  }
+  for (const moduleName of ["product_lock", "image_job", "scene", "composition", "lighting_material", "negative_constraints"]) {
+    if (!isText(promptModules[moduleName])) errors.push(`images[${index}].prompt_modules.${moduleName} is required`);
+  }
+  const productLockLength = [...(promptModules.product_lock ?? "")].length;
+  if (productLockLength < 20 || productLockLength > 160) {
+    errors.push(`images[${index}].prompt_modules.product_lock must be 20..160 characters (${productLockLength})`);
+  }
+  if (isText(identityManifest.product_lock) && promptModules.product_lock !== identityManifest.product_lock) {
+    errors.push(`images[${index}].prompt_modules.product_lock must exactly match product_understanding.identity_manifest.product_lock`);
+  }
+  const positivePromptText = [
+    promptModules.image_job,
+    promptModules.scene,
+    promptModules.composition,
+    promptModules.lighting_material,
+    promptModules.camera_style,
+  ].filter(Boolean).join(" ");
+  for (const unknownFeature of [
+    ...asArray(understanding.unknown_features),
+    ...asArray(identityManifest.unknown_do_not_assert),
+  ].filter(isText)) {
+    if (positivePromptText.includes(unknownFeature)) {
+      errors.push(`images[${index}] positively asserts unknown product feature: ${unknownFeature}`);
+    }
+  }
+  const absentText = asArray(identityManifest.must_not_add).filter(isText).join(" ").toLowerCase();
+  const positiveLower = positivePromptText.toLowerCase();
+  const contradictionGroups = [
+    { absent: ["纽扣", "button"], positive: ["单排扣", "双排扣", "single-breasted", "double-breasted", "buttoned"] },
+    { absent: ["口袋", "pocket"], positive: ["口袋", "pocket"] },
+    { absent: ["拉链", "zipper"], positive: ["拉链", "zipper", "zip closure"] },
+    { absent: ["腰带", "belt"], positive: ["腰带", "belted", "belt"] },
+  ];
+  for (const group of contradictionGroups) {
+    if (
+      group.absent.some((term) => absentText.includes(term))
+      && group.positive.some((term) => positiveLower.includes(term))
+    ) {
+      errors.push(`images[${index}] prompt contradicts identity_manifest.must_not_add`);
+      break;
+    }
+  }
+  const usesPanelLayout = /三格|多格|分格|面板|横向序列|纵向序列|separate panels|multi[- ]?panel|\bpanel\b|\bgrid\b/i.test(positivePromptText);
+  if (usesPanelLayout && image.shot_spec?.framing_mode !== "multi_panel") {
+    errors.push(`images[${index}] uses a panel/grid layout without framing_mode=multi_panel`);
+  }
+  const complexityRule = complexityRules[setDesign.scene_complexity];
+  const sceneLength = [...(promptModules.scene ?? "")].length;
+  if (complexityRule && sceneLength < complexityRule.sceneMin) {
+    errors.push(`images[${index}].prompt_modules.scene is too short for ${setDesign.scene_complexity} complexity (${sceneLength} < ${complexityRule.sceneMin})`);
+  }
+  if (setDesign.scene_complexity === "complex") {
+    if (!isText(promptModules.camera_style)) {
+      errors.push(`images[${index}].prompt_modules.camera_style is required for complex scenes`);
+    }
+    if (sceneLength <= productLockLength * 1.25) {
+      errors.push(`images[${index}] complex scene detail must be longer than the product lock`);
+    }
+  }
+  if (isText(promptModules.text_layout)) {
+    errors.push(`images[${index}].prompt_modules.text_layout must be empty until deterministic text compositing is available`);
+  }
+  if (!isText(image.generation_prompt)) errors.push(`images[${index}].generation_prompt is required`);
+  const promptLength = [...(image.generation_prompt ?? "")].length;
+  if (complexityRule && promptLength < complexityRule.promptMin) {
+    errors.push(`images[${index}].generation_prompt is too short for ${setDesign.scene_complexity} complexity (${promptLength} < ${complexityRule.promptMin})`);
+  }
+  if (promptLength > 800) errors.push(`images[${index}].generation_prompt exceeds 800 characters (${promptLength})`);
+  const normalizedPrompt = (image.generation_prompt ?? "").replace(/\s+/g, "");
+  for (const moduleName of moduleNames) {
+    const moduleText = (promptModules[moduleName] ?? "").replace(/\s+/g, "");
+    if (moduleText && !normalizedPrompt.includes(moduleText)) {
+      errors.push(`images[${index}].generation_prompt does not contain prompt_modules.${moduleName}`);
+    }
+  }
+  if (typeof image.negative_prompt !== "string") errors.push(`images[${index}].negative_prompt must be a string`);
+  const finalRequestPrompt = image.negative_prompt?.trim()
+    ? `${image.generation_prompt}\n禁止:${image.negative_prompt}`
+    : image.generation_prompt;
+  if ([...(finalRequestPrompt ?? "")].length > 800) {
+    errors.push(`images[${index}] final request prompt exceeds 800 characters after negative_prompt is appended`);
+  }
+  const negativeTokens = (image.negative_prompt ?? "").toLowerCase().split(/[,,、;;\s]+/).filter(Boolean);
+  if (negativeTokens.includes("logo") || negativeTokens.includes("文字") || negativeTokens.includes("品牌")) {
+    errors.push(`images[${index}].negative_prompt conflicts with required product label; use 额外文字/额外品牌标识 instead`);
+  }
+  if (!Array.isArray(image.overlay_copy)) {
+    errors.push(`images[${index}].overlay_copy must be an array`);
+  } else if (image.overlay_copy.length > 0) {
+    errors.push(`images[${index}].overlay_copy must stay empty until deterministic text compositing is available`);
+  }
+  if (!Array.isArray(image.reference_images) || image.reference_images.length === 0) {
+    errors.push(`images[${index}].reference_images must contain a product reference`);
+  }
+  if (image.model_parameters?.adapter !== "jimeng_img_v4") errors.push(`images[${index}].model_parameters.adapter must be jimeng_img_v4`);
+  if (!Number.isInteger(image.model_parameters?.width) || !Number.isInteger(image.model_parameters?.height)) {
+    errors.push(`images[${index}] requires integer width and height`);
+  }
+  if (!(image.model_parameters?.scale >= 0 && image.model_parameters.scale <= 1)) {
+    errors.push(`images[${index}].model_parameters.scale must be 0..1`);
+  }
+  if (image.model_parameters?.force_single !== true) errors.push(`images[${index}].model_parameters.force_single must be true`);
+  if (!Array.isArray(image.acceptance_checks) || image.acceptance_checks.length === 0) {
+    errors.push(`images[${index}].acceptance_checks must be non-empty`);
+  }
+
+  creativeBodies.push([
+    image.decision_subject,
+    setDesign.unique_visual_hook,
+    promptModules.image_job,
+    promptModules.scene,
+    promptModules.composition,
+  ].filter(Boolean).join(" ").replace(/\s+/g, "").toLowerCase());
+
+  const claimText = [
+    promptModules.image_job,
+    promptModules.scene,
+    ...asArray(image.overlay_copy),
+  ].filter(Boolean).join(" ");
+  for (const claim of asArray(understanding.unsupported_claims).filter(isText)) {
+    if (claimText.includes(claim)) errors.push(`images[${index}] uses unsupported claim: ${claim}`);
+  }
+  for (const term of highRiskClaimTerms) {
+    if (claimText.includes(term) && !evidenceText.includes(term)) {
+      errors.push(`images[${index}] uses unsupported high-risk claim term: ${term}`);
+    }
+  }
+}
+
+if (plan.status === "ready") {
+  const expected = ["01", "02", "03", "04", "05", "06", "07"];
+  if (JSON.stringify(ids) !== JSON.stringify(expected)) errors.push("image IDs must be ordered 01 through 07");
+  if (plan.images?.[0]?.business_category !== "hero") errors.push("images[0] must be the hero image");
+  if (plan.images?.[0]?.set_design?.render_family !== "isolated_product") {
+    errors.push("images[0] must use isolated_product");
+  }
+
+  const categoryCounts = countValues(categoryValues);
+  for (const [category, required] of Object.entries(requiredAllocation)) {
+    if ((categoryCounts.get(category) ?? 0) !== required) {
+      errors.push(`business_category ${category} must appear exactly ${required} time(s)`);
+    }
+  }
+  if (new Set(decisions).size !== decisions.length) errors.push("decision_subject must be unique across all seven images");
+  if (sellingImages.length === 2 && sellingImages[0].selling_point_id === sellingImages[1].selling_point_id) {
+    errors.push("selling_point images must use different selling_point_id values");
+  }
+  for (const category of ["scene", "selling_point", "decision_support"]) {
+    const values = categoryRenderFamilies.get(category) ?? [];
+    if (values.length === 2 && new Set(values).size !== 2) {
+      errors.push(`${category} images must use different render_family values`);
+    }
+  }
+  if (staticProductCount > 2) {
+    errors.push(`static product render families may appear at most twice across the set (${staticProductCount})`);
+  }
+
+  const diversityRules = [
+    ["narrative_job", 7, 1],
+    ["composition_family", 5, 2],
+    ["shot_scale", 4, 2],
+    ["camera_angle", 4, 3],
+    ["background_family", 5, 2],
+    ["lighting_family", 4, 2],
+    ["interaction_mode", 5, 2],
+    ["camera_axis", 4, 2],
+    ["framing_mode", 4, 2],
+  ];
+  for (const [field, minimumUnique, maximumRepeats] of diversityRules) {
+    const values = shotSpecs.map((spec) => spec?.[field]).filter(isText);
+    const counts = countValues(values);
+    if (new Set(values).size < minimumUnique) errors.push(`shot_spec.${field} requires at least ${minimumUnique} distinct values`);
+    for (const [value, count] of counts) {
+      if (count > maximumRepeats) errors.push(`shot_spec.${field} repeats too often: ${value} (${count})`);
+    }
+  }
+  const framingCounts = countValues(shotSpecs.map((spec) => spec?.framing_mode).filter(isText));
+  if ((framingCounts.get("detail") ?? 0) > 1) {
+    errors.push("shot_spec.framing_mode=detail may appear at most once across the set");
+  }
+  if ((framingCounts.get("multi_panel") ?? 0) > 1) {
+    errors.push("shot_spec.framing_mode=multi_panel may appear at most once across the set");
+  }
+
+  const occupancies = shotSpecs.map((spec) => spec?.product_occupancy_pct).filter(Number.isFinite);
+  if (occupancies.length === 7 && Math.max(...occupancies) - Math.min(...occupancies) < 35) {
+    errors.push("product occupancy range must span at least 35 percentage points across the set");
+  }
+
+  const nonHeroSignatures = shotSpecs.slice(1).map((spec) => [
+    spec.camera_axis,
+    spec.framing_mode,
+    spec.product_pose,
+  ].join("|"));
+  if (new Set(nonHeroSignatures).size !== nonHeroSignatures.length) {
+    errors.push("all non-hero images require distinct camera_axis + framing_mode + product_pose visual signatures");
+  }
+  for (let left = 1; left < shotSpecs.length; left += 1) {
+    for (let right = left + 1; right < shotSpecs.length; right += 1) {
+      const a = shotSpecs[left];
+      const b = shotSpecs[right];
+      if (
+        a.camera_axis === b.camera_axis
+        && a.framing_mode === b.framing_mode
+        && Math.abs(a.product_occupancy_pct - b.product_occupancy_pct) < 15
+      ) {
+        errors.push(`images ${ids[left]} and ${ids[right]} reuse the same camera axis/framing with insufficient occupancy contrast`);
+      }
+    }
+  }
+
+  const tokens = (text) => {
+    const latin = text.match(/[a-z0-9]{3,}/g) ?? [];
+    const hanRuns = text.match(/\p{Script=Han}+/gu) ?? [];
+    const hanBigrams = hanRuns.flatMap((run) => Array.from(
+      { length: Math.max(0, run.length - 1) },
+      (_, index) => run.slice(index, index + 2),
+    ));
+    return new Set([...latin, ...hanBigrams]);
+  };
+  const similarity = (left, right) => {
+    const a = tokens(left);
+    const b = tokens(right);
+    const intersection = [...a].filter((item) => b.has(item)).length;
+    const union = new Set([...a, ...b]).size;
+    return union ? intersection / union : 0;
+  };
+  for (let left = 0; left < creativeBodies.length; left += 1) {
+    for (let right = left + 1; right < creativeBodies.length; right += 1) {
+      const score = similarity(creativeBodies[left], creativeBodies[right]);
+      if (score > 0.72) errors.push(`creative prompts ${ids[left]} and ${ids[right]} are too similar (${score.toFixed(2)})`);
+    }
+  }
+}
+
+const secretKey = /(token|api.?key|authorization|password|secret)/i;
+const scan = (value, path = "plan") => {
+  if (!value || typeof value !== "object") return;
+  for (const [key, child] of Object.entries(value)) {
+    if (secretKey.test(key)) errors.push(`${path}.${key} must not contain credentials`);
+    scan(child, `${path}.${key}`);
+  }
+};
+scan(plan);
+
+if (errors.length) {
+  console.error(JSON.stringify({ valid: false, errors }, null, 2));
+  process.exit(1);
+}
+
+console.log(JSON.stringify({ valid: true, run_id: plan.run_id, images: plan.images.length }, null, 2));

+ 187 - 0
claude-code/fmode-image-set/mcp/src/features/image-set/validate-plan.test.mjs

@@ -0,0 +1,187 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { buildValidPlan, compilePrompt } from "../../../../scripts/test-plan-fixture.mjs";
+
+const execFileAsync = promisify(execFile);
+const validatorFile = fileURLToPath(new URL("./validate-plan.mjs", import.meta.url));
+
+async function validate(filename) {
+  return execFileAsync(process.execPath, [validatorFile, filename]);
+}
+
+async function withPlan(plan, name, operation) {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-plan-test-"));
+  try {
+    const filename = join(directory, name);
+    await writeFile(filename, JSON.stringify(plan));
+    return await operation(filename);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+}
+
+test("valid 1+2+2+2 plan passes validation", async () => {
+  await withPlan(buildValidPlan(), "valid.json", async (filename) => {
+    const { stdout } = await validate(filename);
+    const result = JSON.parse(stdout);
+    assert.equal(result.valid, true);
+    assert.equal(result.images, 7);
+  });
+});
+
+test("the same planner schema works across unrelated product categories", async () => {
+  for (const category of ["wireless keyboard", "canvas backpack", "ceramic dinner plate"]) {
+    await withPlan(buildValidPlan(category), `${category.replace(/\s+/g, "-")}.json`, async (filename) => {
+      const { stdout } = await validate(filename);
+      assert.equal(JSON.parse(stdout).valid, true);
+    });
+  }
+});
+
+test("validator rejects an incorrect business allocation", async () => {
+  const plan = buildValidPlan();
+  plan.images[6].business_category = "selling_point";
+  plan.images[6].selling_point_id = "visible-sp-1";
+  plan.images[6].evidence_refs = ["visible-sp-1"];
+  plan.images[6].set_design.render_family = "feature_demonstration";
+  await withPlan(plan, "bad-allocation.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("business_category selling_point")));
+      assert.ok(result.errors.some((item) => item.includes("business_category decision_support")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects duplicate selling points and render families", async () => {
+  const plan = buildValidPlan();
+  plan.images[3].selling_point_id = "visible-sp-1";
+  plan.images[3].evidence_refs = ["visible-sp-1"];
+  plan.images[3].set_design.render_family = "material_demonstration";
+  await withPlan(plan, "duplicate-selling-point.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("different selling_point_id")));
+      assert.ok(result.errors.some((item) => item.includes("different render_family")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects more than two static product render families", async () => {
+  const plan = buildValidPlan();
+  plan.images[3].set_design.render_family = "annotated_detail";
+  await withPlan(plan, "static-overload.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("static product render families")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects fake information layouts without a compositor", async () => {
+  const plan = buildValidPlan();
+  plan.images[4].set_design.render_family = "structured_information";
+  plan.images[4].overlay_copy = ["VISIBLE CLAIM"];
+  plan.images[4].prompt_modules.text_layout = "Reserve a copy panel.";
+  plan.images[4].generation_prompt = compilePrompt(plan.images[4].prompt_modules);
+  await withPlan(plan, "fake-information.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("deterministic text compositor")));
+      assert.ok(result.errors.some((item) => item.includes("overlay_copy must stay empty")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects a complex scene compressed to a short template", async () => {
+  const plan = buildValidPlan();
+  const image = plan.images[2];
+  image.prompt_modules.scene = "A product on a table.";
+  image.generation_prompt = compilePrompt(image.prompt_modules);
+  await withPlan(plan, "short-complex-scene.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("scene is too short for complex complexity")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects unsupported product claims", async () => {
+  const plan = buildValidPlan();
+  plan.images[1].prompt_modules.image_job += " 经过认证。";
+  plan.images[1].generation_prompt = compilePrompt(plan.images[1].prompt_modules);
+  await withPlan(plan, "unsupported-claim.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("uses unsupported claim: 经过认证")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects different business roles that reuse the same visual angle", async () => {
+  const plan = buildValidPlan();
+  plan.images[4].shot_spec.camera_axis = plan.images[2].shot_spec.camera_axis;
+  plan.images[4].shot_spec.framing_mode = plan.images[2].shot_spec.framing_mode;
+  plan.images[4].shot_spec.product_pose = plan.images[2].shot_spec.product_pose;
+  plan.images[4].shot_spec.product_occupancy_pct = plan.images[2].shot_spec.product_occupancy_pct + 5;
+  await withPlan(plan, "duplicate-visual-angle.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("distinct camera_axis")));
+      assert.ok(result.errors.some((item) => item.includes("insufficient occupancy contrast")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects a prompt that rewrites the audited product lock", async () => {
+  const plan = buildValidPlan();
+  plan.images[2].prompt_modules.product_lock = "严格保留参考商品,但允许根据场景重新设计商品结构与颜色。";
+  plan.images[2].generation_prompt = compilePrompt(plan.images[2].prompt_modules);
+  await withPlan(plan, "rewritten-lock.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("must exactly match")));
+      return true;
+    });
+  });
+});
+
+test("validator rejects invented closure details that contradict the identity manifest", async () => {
+  const plan = buildValidPlan();
+  plan.images[1].prompt_modules.scene += " 画面清楚展示单排扣闭合结构。";
+  plan.images[1].generation_prompt = compilePrompt(plan.images[1].prompt_modules);
+  await withPlan(plan, "invented-buttons.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("contradicts identity_manifest.must_not_add")));
+      return true;
+    });
+  });
+});
+
+test("validator allows only one detail frame and one multi-panel frame", async () => {
+  const plan = buildValidPlan();
+  plan.images[3].shot_spec.framing_mode = "detail";
+  plan.images[3].shot_spec.camera_axis = "side";
+  plan.images[3].shot_spec.product_pose = "arranged_sequence";
+  await withPlan(plan, "duplicate-detail.json", async (filename) => {
+    await assert.rejects(validate(filename), (error) => {
+      const result = JSON.parse(error.stderr);
+      assert.ok(result.errors.some((item) => item.includes("framing_mode=detail may appear at most once")));
+      return true;
+    });
+  });
+});

+ 287 - 0
claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.mjs

@@ -0,0 +1,287 @@
+import { createHash } from "node:crypto";
+import { open, mkdir, readdir, stat, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const DEFAULT_SKILL_NAME = "fmode-image-set";
+const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
+const MAX_SESSION_TAIL_BYTES = 8 * 1024 * 1024;
+const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
+
+const mediaTypes = new Map([
+  ["image/jpeg", { extension: ".jpg", signature: (buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff }],
+  ["image/png", { extension: ".png", signature: (buffer) => buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) }],
+  ["image/webp", { extension: ".webp", signature: (buffer) => buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP" }],
+  ["image/gif", { extension: ".gif", signature: (buffer) => ["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii")) }],
+]);
+
+export class AttachmentResolutionError extends Error {
+  constructor(code, message) {
+    super(message);
+    this.name = "AttachmentResolutionError";
+    this.code = code;
+  }
+}
+
+function normalizePath(value) {
+  return resolve(value).replaceAll("\\", "/").toLowerCase();
+}
+
+async function listSessionFiles(rootDirectory) {
+  const files = [];
+  const pending = [rootDirectory];
+  while (pending.length > 0) {
+    const directory = pending.pop();
+    let entries;
+    try {
+      entries = await readdir(directory, { withFileTypes: true });
+    } catch (error) {
+      if (error?.code === "ENOENT") return [];
+      throw error;
+    }
+    for (const entry of entries) {
+      const path = join(directory, entry.name);
+      if (entry.isDirectory()) pending.push(path);
+      else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path);
+    }
+  }
+  return files;
+}
+
+async function readSessionTail(file, knownStat = null) {
+  const fileStat = knownStat ?? await stat(file);
+  const start = Math.max(0, fileStat.size - MAX_SESSION_TAIL_BYTES);
+  const length = fileStat.size - start;
+  const handle = await open(file, "r");
+  try {
+    const buffer = Buffer.alloc(length);
+    await handle.read(buffer, 0, length, start);
+    let text = buffer.toString("utf8");
+    if (start > 0) {
+      const firstNewline = text.indexOf("\n");
+      text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
+    }
+    const records = [];
+    for (const line of text.split(/\r?\n/)) {
+      if (!line.trim()) continue;
+      try {
+        records.push(JSON.parse(line));
+      } catch {
+        // Ignore an incomplete trailing line while Claude Code is appending.
+      }
+    }
+    return { records, fileStat };
+  } finally {
+    await handle.close();
+  }
+}
+
+function contentParts(content) {
+  if (Array.isArray(content)) return content;
+  if (typeof content === "string") return [{ type: "text", text: content }];
+  return [];
+}
+
+function latestSkillInvocation(records, skillName, expectedCwd) {
+  const groups = new Map();
+  records.forEach((record, index) => {
+    if (record.type !== "user" || record.message?.role !== "user" || !record.promptId) return;
+    const group = groups.get(record.promptId) ?? {
+      promptId: record.promptId,
+      timestamp: record.timestamp,
+      cwd: record.cwd,
+      lastIndex: index,
+      texts: [],
+      images: [],
+    };
+    group.timestamp = record.timestamp || group.timestamp;
+    group.cwd = record.cwd || group.cwd;
+    group.lastIndex = index;
+    for (const part of contentParts(record.message.content)) {
+      if (part?.type === "text" && typeof part.text === "string") group.texts.push(part.text);
+      if (part?.type === "image") group.images.push(part);
+    }
+    groups.set(record.promptId, group);
+  });
+
+  const expected = normalizePath(expectedCwd);
+  const commandMarker = `<command-name>/${skillName}</command-name>`;
+  const skillPathMarkers = [
+    `/.claude/skills/${skillName}`,
+    `\\.claude\\skills\\${skillName}`,
+  ];
+  return [...groups.values()]
+    .filter((group) => group.cwd && normalizePath(group.cwd) === expected)
+    .filter((group) => {
+      const text = group.texts.join("\n");
+      return text.includes(commandMarker) || skillPathMarkers.some((marker) => text.toLowerCase().includes(marker.toLowerCase()));
+    })
+    .sort((a, b) => {
+      const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0);
+      return timeDifference || b.lastIndex - a.lastIndex;
+    })[0] ?? null;
+}
+
+function decodeImage(part) {
+  const source = part?.source;
+  if (source?.type !== "base64" || typeof source.data !== "string") {
+    throw new AttachmentResolutionError("ATTACHMENT_FORMAT_UNSUPPORTED", "The current Claude attachment is not an embedded base64 image");
+  }
+  const media = mediaTypes.get(source.media_type);
+  if (!media) {
+    throw new AttachmentResolutionError("ATTACHMENT_TYPE_UNSUPPORTED", `Unsupported attachment media type: ${source.media_type || "unknown"}`);
+  }
+  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(source.data) || source.data.length % 4 !== 0) {
+    throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment contains invalid base64 data");
+  }
+  const buffer = Buffer.from(source.data, "base64");
+  if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES || !media.signature(buffer)) {
+    throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment is empty, too large, or does not match its declared image type");
+  }
+  return { buffer, mediaType: source.media_type, extension: media.extension };
+}
+
+export async function inspectLatestClaudeInvocation({
+  cwd = process.cwd(),
+  claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
+  sessionId = process.env.CLAUDE_CODE_SESSION_ID || "",
+  skillName = DEFAULT_SKILL_NAME,
+  maxAgeMs = DEFAULT_MAX_AGE_MS,
+  now = Date.now(),
+} = {}) {
+  const resolvedClaudeConfigDirectory = claudeConfigDirectory || join(homedir(), ".claude");
+  const projectsDirectory = join(resolvedClaudeConfigDirectory, "projects");
+  let files = await listSessionFiles(projectsDirectory);
+  if (sessionId) {
+    files = files.filter((file) => file.toLowerCase().endsWith(`${sessionId.toLowerCase()}.jsonl`));
+    if (files.length === 0) {
+      throw new AttachmentResolutionError("CLAUDE_SESSION_NOT_FOUND", `Claude session ${sessionId} was not found`);
+    }
+  }
+  const candidates = [];
+  for (const file of files) {
+    let parsed;
+    try {
+      const fileStat = await stat(file);
+      if (!sessionId && now - fileStat.mtimeMs > maxAgeMs) continue;
+      parsed = await readSessionTail(file, fileStat);
+    } catch {
+      continue;
+    }
+    const invocation = latestSkillInvocation(parsed.records, skillName, cwd);
+    if (!invocation) continue;
+    candidates.push({
+      file,
+      sessionId: basename(file, ".jsonl"),
+      fileMtimeMs: parsed.fileStat.mtimeMs,
+      ...invocation,
+    });
+  }
+  if (candidates.length === 0) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "No recent Claude Code skill invocation was found for the current project");
+  }
+  candidates.sort((a, b) => {
+    const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0);
+    return timeDifference || b.fileMtimeMs - a.fileMtimeMs;
+  });
+  const current = candidates[0];
+  const promptTime = Date.parse(current.timestamp || 0);
+  if (!Number.isFinite(promptTime) || now - promptTime > maxAgeMs) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_EXPIRED", "The latest Claude Code skill invocation is too old");
+  }
+  return {
+    prompt_id: current.promptId,
+    session_id: current.sessionId,
+    captured_at: current.timestamp,
+    image_count: current.images.length,
+    current,
+  };
+}
+
+export async function extractLatestClaudeAttachment({
+  cwd = process.cwd(),
+  outputDirectory,
+  claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
+  sessionId = process.env.CLAUDE_CODE_SESSION_ID || "",
+  skillName = DEFAULT_SKILL_NAME,
+  maxAgeMs = DEFAULT_MAX_AGE_MS,
+  now = Date.now(),
+} = {}) {
+  if (!outputDirectory) throw new AttachmentResolutionError("ATTACHMENT_OUTPUT_REQUIRED", "An attachment output directory is required");
+  const invocation = await inspectLatestClaudeInvocation({
+    cwd,
+    claudeConfigDirectory,
+    sessionId,
+    skillName,
+    maxAgeMs,
+    now,
+  });
+  const current = invocation.current;
+  if (current.images.length === 0) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "The current skill invocation does not contain an image attachment");
+  }
+  if (current.images.length > 1) {
+    throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_AMBIGUOUS", "The current skill invocation contains more than one image attachment");
+  }
+
+  const decoded = decodeImage(current.images[0]);
+  const sha256 = createHash("sha256").update(decoded.buffer).digest("hex");
+  const outputFile = resolve(outputDirectory, `reference-${sha256.slice(0, 16)}${decoded.extension}`);
+  await mkdir(dirname(outputFile), { recursive: true });
+  await writeFile(outputFile, decoded.buffer);
+  return {
+    schema_version: "0.1",
+    status: "complete",
+    source: "claude_code_attachment",
+    file_path: outputFile,
+    media_type: decoded.mediaType,
+    sha256,
+    prompt_id: current.promptId,
+    session_id: current.sessionId,
+    captured_at: current.timestamp,
+  };
+}
+
+function option(args, name, fallback = "") {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+}
+
+async function runCli() {
+  const args = process.argv.slice(2);
+  const outputDirectory = option(args, "--output-dir");
+  const resultFile = option(args, "--result");
+  const maxAgeMinutes = Number(option(args, "--max-age-minutes", "30"));
+  let result;
+  try {
+    result = await extractLatestClaudeAttachment({
+      cwd: option(args, "--cwd", process.cwd()),
+      outputDirectory,
+      claudeConfigDirectory: option(args, "--claude-config-dir", process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")),
+      sessionId: option(args, "--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+      skillName: option(args, "--skill", DEFAULT_SKILL_NAME),
+      maxAgeMs: Number.isFinite(maxAgeMinutes) && maxAgeMinutes > 0 ? maxAgeMinutes * 60 * 1000 : DEFAULT_MAX_AGE_MS,
+    });
+  } catch (error) {
+    result = {
+      schema_version: "0.1",
+      status: "blocked",
+      error: {
+        code: error?.code || "CLAUDE_ATTACHMENT_RESOLUTION_FAILED",
+        message: error?.message || "Claude attachment resolution failed",
+      },
+    };
+  }
+  if (resultFile) {
+    const absoluteResult = resolve(resultFile);
+    await mkdir(dirname(absoluteResult), { recursive: true });
+    await writeFile(absoluteResult, `${JSON.stringify(result, null, 2)}\n`, "utf8");
+  }
+  console.log(JSON.stringify(result, null, 2));
+}
+
+const invokedFile = process.argv[1] ? resolve(process.argv[1]) : "";
+if (invokedFile && normalizePath(invokedFile) === normalizePath(fileURLToPath(import.meta.url))) {
+  await runCli();
+}

+ 213 - 0
claude-code/fmode-image-set/mcp/src/providers/claude-attachment-client.test.mjs

@@ -0,0 +1,213 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { AttachmentResolutionError, extractLatestClaudeAttachment } from "./claude-attachment-client.mjs";
+import { createRun } from "../core/run-paths.mjs";
+
+const skillName = "fmode-image-set";
+const cwd = "D:\\Angular\\套图设计";
+const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
+const execFileAsync = promisify(execFile);
+const visionClientFile = fileURLToPath(new URL("../features/image-set/product-vision-client.mjs", import.meta.url));
+const createRunFile = fileURLToPath(new URL("../tools/create-run.mjs", import.meta.url));
+
+function userRecord({ promptId, timestamp, content }) {
+  return {
+    type: "user",
+    promptId,
+    timestamp,
+    cwd,
+    message: { role: "user", content },
+  };
+}
+
+async function writeSession(configDirectory, sessionId, records) {
+  const projectDirectory = join(configDirectory, "projects", "demo-project");
+  await mkdir(projectDirectory, { recursive: true });
+  const file = join(projectDirectory, `${sessionId}.jsonl`);
+  await writeFile(file, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, "utf8");
+  return file;
+}
+
+function invocationRecords(promptId, timestamp, images = [jpeg]) {
+  return [
+    userRecord({
+      promptId,
+      timestamp,
+      content: `<command-message>${skillName}</command-message>\n<command-name>/${skillName}</command-name>`,
+    }),
+    userRecord({
+      promptId,
+      timestamp,
+      content: [
+        ...images.map((image) => ({
+          type: "image",
+          source: { type: "base64", media_type: "image/jpeg", data: image.toString("base64") },
+        })),
+        { type: "text", text: `Base directory for this skill: ${cwd}\\.claude\\skills\\${skillName}` },
+      ],
+    }),
+  ];
+}
+
+test("extracts the unique image from the latest matching Claude skill invocation", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-test-"));
+  try {
+    const configDirectory = join(directory, ".claude");
+    const outputDirectory = join(directory, "run", "attachments");
+    const timestamp = "2026-07-23T06:31:35.973Z";
+    await writeSession(configDirectory, "11111111-1111-1111-1111-111111111111", invocationRecords("prompt-current", timestamp));
+    const result = await extractLatestClaudeAttachment({
+      cwd,
+      outputDirectory,
+      claudeConfigDirectory: configDirectory,
+      now: Date.parse(timestamp) + 1_000,
+    });
+    assert.equal(result.status, "complete");
+    assert.equal(result.prompt_id, "prompt-current");
+    assert.equal(result.media_type, "image/jpeg");
+    assert.deepEqual(await readFile(result.file_path), jpeg);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("does not reuse an older attachment when the latest invocation has no image", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-stale-test-"));
+  try {
+    const configDirectory = join(directory, ".claude");
+    const oldRecords = invocationRecords("prompt-old", "2026-07-23T06:30:00.000Z");
+    const currentRecords = invocationRecords("prompt-current", "2026-07-23T06:31:00.000Z", []);
+    await writeSession(configDirectory, "22222222-2222-2222-2222-222222222222", [...oldRecords, ...currentRecords]);
+    await assert.rejects(
+      extractLatestClaudeAttachment({
+        cwd,
+        outputDirectory: join(directory, "run"),
+        claudeConfigDirectory: configDirectory,
+        now: Date.parse("2026-07-23T06:31:01.000Z"),
+      }),
+      (error) => error instanceof AttachmentResolutionError && error.code === "CLAUDE_ATTACHMENT_NOT_FOUND",
+    );
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("fails closed when the current invocation contains multiple images", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-ambiguous-test-"));
+  try {
+    const configDirectory = join(directory, ".claude");
+    const timestamp = "2026-07-23T06:31:00.000Z";
+    await writeSession(configDirectory, "33333333-3333-3333-3333-333333333333", invocationRecords("prompt-current", timestamp, [jpeg, jpeg]));
+    await assert.rejects(
+      extractLatestClaudeAttachment({
+        cwd,
+        outputDirectory: join(directory, "run"),
+        claudeConfigDirectory: configDirectory,
+        now: Date.parse(timestamp) + 1_000,
+      }),
+      (error) => error instanceof AttachmentResolutionError && error.code === "CLAUDE_ATTACHMENT_AMBIGUOUS",
+    );
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("product vision client accepts the Claude attachment bridge without a manual image path", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-attachment-integration-test-"));
+  const paths = await createRun();
+  try {
+    const configDirectory = join(directory, ".claude");
+    const timestamp = "2026-07-23T06:31:00.000Z";
+    await writeSession(configDirectory, "44444444-4444-4444-4444-444444444444", invocationRecords("prompt-current", timestamp));
+    const outputFile = paths.vision_result;
+    const { stdout } = await execFileAsync(process.execPath, [
+      visionClientFile,
+      "--from-claude-attachment",
+      "--output", outputFile,
+      "--dry-run",
+      "--attachment-cwd", cwd,
+      "--claude-config-dir", configDirectory,
+      "--attachment-max-age-minutes", "10000",
+    ], {
+      env: { ...process.env },
+    });
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "dry-run");
+    assert.equal(result.vision_called, false);
+    assert.equal(result.reference.kind, "local");
+    assert.equal(result.reference.attachment.prompt_id, "prompt-current");
+    assert.ok(result.reference.source.startsWith(paths.attachments_directory));
+    assert.deepEqual(await readFile(result.reference.source), jpeg);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("create-run CLI reuses the run assigned to the same Claude prompt", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-create-run-lock-test-"));
+  let paths;
+  let indexDirectory;
+  try {
+    const configDirectory = join(directory, ".claude");
+    const sessionId = "55555555-5555-5555-5555-555555555555";
+    const timestamp = new Date().toISOString();
+    await writeSession(configDirectory, sessionId, invocationRecords("prompt-locked", timestamp));
+    const command = () => execFileAsync(process.execPath, [
+      createRunFile,
+      "--cwd", cwd,
+      "--claude-config-dir", configDirectory,
+      "--session-id", sessionId,
+    ]);
+    const first = JSON.parse((await command()).stdout);
+    const second = JSON.parse((await command()).stdout);
+    paths = first;
+    indexDirectory = join(first.runs_root, ".request-index", first.request_key_hash);
+    assert.equal(first.reused_existing_run, false);
+    assert.equal(second.reused_existing_run, true);
+    assert.equal(second.run_id, first.run_id);
+  } finally {
+    if (paths) await rm(paths.run_directory, { recursive: true, force: true });
+    if (indexDirectory) await rm(indexDirectory, { recursive: true, force: true });
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("empty CLAUDE_CONFIG_DIR falls back to the user Claude directory", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-empty-claude-config-test-"));
+  let paths;
+  let indexDirectory;
+  try {
+    const configDirectory = join(directory, ".claude");
+    const sessionId = "66666666-6666-6666-6666-666666666666";
+    const timestamp = new Date().toISOString();
+    await writeSession(configDirectory, sessionId, invocationRecords("prompt-empty-config", timestamp));
+    const { stdout } = await execFileAsync(process.execPath, [
+      createRunFile,
+      "--cwd", cwd,
+    ], {
+      env: {
+        ...process.env,
+        CLAUDE_CONFIG_DIR: "",
+        CLAUDE_CODE_SESSION_ID: sessionId,
+        HOME: directory,
+        USERPROFILE: directory,
+        FMODE_IMAGE_SET_PROJECT_ROOT: directory,
+      },
+    });
+    paths = JSON.parse(stdout);
+    indexDirectory = join(paths.runs_root, ".request-index", paths.request_key_hash);
+    assert.equal(paths.reused_existing_run, false);
+    assert.equal(paths.request_key_hash.length, 64);
+  } finally {
+    if (paths) await rm(paths.run_directory, { recursive: true, force: true });
+    if (indexDirectory) await rm(indexDirectory, { recursive: true, force: true });
+    await rm(directory, { recursive: true, force: true });
+  }
+});

+ 147 - 0
claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.mjs

@@ -0,0 +1,147 @@
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { resolve } from "node:path";
+
+const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
+
+export function parseJsonContent(content) {
+  if (typeof content !== "string") throw new Error("Vision model returned no text content");
+  const cleaned = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
+  const start = cleaned.indexOf("{");
+  const end = cleaned.lastIndexOf("}");
+  if (start < 0 || end <= start) throw new Error("Vision model did not return a JSON object");
+  return JSON.parse(cleaned.slice(start, end + 1));
+}
+
+export async function loadVisionConfig() {
+  let fileConfig = {};
+  try {
+    const raw = await readFile(resolve(homedir(), ".openclaw", "commerce-vision.json"), "utf8");
+    fileConfig = JSON.parse(raw.replace(/^\uFEFF/, ""));
+  } catch (error) {
+    if (error?.code !== "ENOENT") throw new Error(`Cannot read vision credentials: ${error.message}`);
+  }
+  return {
+    provider: process.env.COMMERCE_VISION_PROVIDER || fileConfig.provider || "qwen",
+    baseUrl: process.env.COMMERCE_VISION_BASE_URL || fileConfig.baseUrl || "https://dashscope.aliyuncs.com/compatible-mode/v1",
+    model: process.env.COMMERCE_VISION_MODEL || fileConfig.model || "qwen3-vl-plus",
+    apiKey: process.env.COMMERCE_VISION_API_KEY || fileConfig.apiKey,
+  };
+}
+
+export async function requestVisionJson({ config, imageUrls, instruction, temperature = 0.1 }) {
+  if (!config?.apiKey) throw new Error("VISION_PROVIDER_NOT_CONFIGURED");
+  const endpoint = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
+  let lastError;
+  for (let attempt = 1; attempt <= 3; attempt += 1) {
+    try {
+      const response = await fetch(endpoint, {
+        method: "POST",
+        headers: {
+          Authorization: `Bearer ${config.apiKey}`,
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          model: config.model,
+          temperature,
+          messages: [{
+            role: "user",
+            content: [
+              ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
+              { type: "text", text: instruction },
+            ],
+          }],
+        }),
+        signal: AbortSignal.timeout(120_000),
+      });
+      const body = await response.text();
+      let parsed;
+      try { parsed = body ? JSON.parse(body) : {}; } catch { parsed = {}; }
+      if (!response.ok) {
+        const error = new Error(parsed?.error?.message || parsed?.message || `HTTP ${response.status}`);
+        error.status = response.status;
+        throw error;
+      }
+      return {
+        value: parseJsonContent(parsed.choices?.[0]?.message?.content),
+        usage: parsed.usage ?? null,
+      };
+    } catch (error) {
+      lastError = error;
+      const retryable = error?.status === 429 || (error?.status >= 500 && error?.status <= 599);
+      if (!retryable || attempt === 3) break;
+      await sleep(attempt * 750);
+    }
+  }
+  throw lastError ?? new Error("VISION_REQUEST_FAILED");
+}
+
+export function compileProductLock(identityManifest) {
+  const preserve = [...new Set(identityManifest.must_preserve ?? [])].filter(Boolean).slice(0, 7);
+  const absent = [...new Set(identityManifest.must_not_add ?? [])].filter(Boolean).slice(0, 5);
+  const unknown = [...new Set(identityManifest.unknown_do_not_assert ?? [])].filter(Boolean).slice(0, 4);
+  const build = () => {
+    let result = `严格保留参考商品的${preserve.join("、")}。`;
+    if (absent.length) result += `不得增加${absent.join("、")}。`;
+    if (unknown.length) result += `不得推断${unknown.join("、")}。`;
+    return result;
+  };
+  let result = build();
+  while ([...result].length > 160 && unknown.length) {
+    unknown.pop();
+    result = build();
+  }
+  while ([...result].length > 160 && absent.length > 2) {
+    absent.pop();
+    result = build();
+  }
+  while ([...result].length > 160 && preserve.length > 3) {
+    preserve.pop();
+    result = build();
+  }
+  return result;
+}
+
+export async function reviewGeneratedProduct({
+  referenceUrl,
+  generatedUrl,
+  identityManifest,
+  config = null,
+}) {
+  const effectiveConfig = config ?? await loadVisionConfig();
+  const instruction = `你是商品身份一致性审核器。第一张图是原始商品参考图,第二张图是生成图。只检查商品是否仍为同一款,不评价摄影美感。商品身份约束:
+${JSON.stringify(identityManifest)}
+返回严格 JSON:
+{
+  "same_product": true,
+  "confidence": "high|medium|low",
+  "preserved_features": ["已保持的关键特征"],
+  "critical_violations": [{"code":"ADDED_STRUCTURE|REMOVED_STRUCTURE|SHAPE_CHANGED|COLOR_CHANGED|MATERIAL_APPEARANCE_CHANGED|TEXT_CHANGED|MULTI_PRODUCT_INCONSISTENT","detail":"具体问题"}],
+  "notes": "简短说明"
+}
+只要新增或删除结构、颜色或轮廓明显变化、多格图中的商品互不一致,就必须 same_product=false。`;
+  const { value, usage } = await requestVisionJson({
+    config: effectiveConfig,
+    imageUrls: [referenceUrl, generatedUrl],
+    instruction,
+    temperature: 0,
+  });
+  const violations = Array.isArray(value.critical_violations)
+    ? value.critical_violations.map((item) => ({
+      code: String(item?.code ?? "").trim(),
+      detail: String(item?.detail ?? "").trim(),
+    })).filter((item) => item.code && item.detail)
+    : [];
+  const sameProduct = value.same_product === true && violations.length === 0;
+  return {
+    accepted: sameProduct,
+    same_product: sameProduct,
+    confidence: ["high", "medium", "low"].includes(value.confidence) ? value.confidence : "low",
+    preserved_features: Array.isArray(value.preserved_features)
+      ? value.preserved_features.map((item) => String(item).trim()).filter(Boolean)
+      : [],
+    critical_violations: violations,
+    notes: String(value.notes ?? "").trim(),
+    usage,
+  };
+}

+ 59 - 0
claude-code/fmode-image-set/mcp/src/providers/commerce-vision-provider.test.mjs

@@ -0,0 +1,59 @@
+import assert from "node:assert/strict";
+import { createServer } from "node:http";
+import test from "node:test";
+import { compileProductLock, reviewGeneratedProduct } from "./commerce-vision-provider.mjs";
+
+test("compileProductLock creates one deterministic identity constraint", () => {
+  const manifest = {
+    must_preserve: ["方正轮廓", "浅蓝主体", "黑色圆润顶部", "正面标签位置"],
+    must_not_add: ["纽扣", "额外配件"],
+    unknown_do_not_assert: ["内部结构", "真实尺寸"],
+  };
+  const first = compileProductLock(manifest);
+  const second = compileProductLock(manifest);
+  assert.equal(first, second);
+  assert.match(first, /严格保留/);
+  assert.match(first, /不得增加纽扣/);
+  assert.ok([...first].length <= 160);
+});
+
+test("generated-product review rejects structural drift", async () => {
+  const server = createServer(async (request, response) => {
+    let body = "";
+    for await (const chunk of request) body += chunk;
+    const parsed = JSON.parse(body);
+    assert.equal(parsed.messages[0].content.filter((item) => item.type === "image_url").length, 2);
+    response.writeHead(200, { "Content-Type": "application/json" });
+    response.end(JSON.stringify({
+      choices: [{ message: { content: JSON.stringify({
+        same_product: false,
+        confidence: "high",
+        preserved_features: ["主体颜色"],
+        critical_violations: [{ code: "ADDED_STRUCTURE", detail: "生成图新增了参考图不存在的纽扣" }],
+        notes: "reject",
+      }) } }],
+    }));
+  });
+  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+  try {
+    const result = await reviewGeneratedProduct({
+      referenceUrl: "https://images.example.test/reference.png",
+      generatedUrl: "https://images.example.test/generated.png",
+      identityManifest: {
+        must_preserve: ["主体颜色", "完整轮廓", "可见结构"],
+        must_not_add: ["纽扣"],
+        unknown_do_not_assert: [],
+        product_lock: "严格保留主体颜色、完整轮廓、可见结构,不得增加纽扣。",
+      },
+      config: {
+        apiKey: "test-key",
+        model: "test-vl",
+        baseUrl: `http://127.0.0.1:${server.address().port}/v1`,
+      },
+    });
+    assert.equal(result.accepted, false);
+    assert.equal(result.critical_violations[0].code, "ADDED_STRUCTURE");
+  } finally {
+    await new Promise((resolve) => server.close(resolve));
+  }
+});

+ 764 - 0
claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.mjs

@@ -0,0 +1,764 @@
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
+import { execFile } from "node:child_process";
+import { homedir } from "node:os";
+import { dirname, extname, isAbsolute, join, resolve } from "node:path";
+import { promisify } from "node:util";
+import { fileURLToPath } from "node:url";
+import { uploadLocalImage } from "./qiniu-upload-client.mjs";
+import { assertRunFile, pathsForRun } from "../core/run-paths.mjs";
+import { sha256, signState, verifyState } from "../core/run-state-integrity.mjs";
+import { loadVisionConfig, reviewGeneratedProduct } from "./commerce-vision-provider.mjs";
+
+const args = process.argv.slice(2);
+const planFileArg = args[0];
+if (!planFileArg || planFileArg.startsWith("--")) {
+  throw new Error("Usage: node jimeng-v4-client.mjs <prompt-plan.json> --dry-run|--prepare-assets|--execute|--download-only [--vision-result vision-result.json] [--max-cost 1.54] [--asset-map assets.json] [--output result.json] [--download-timeout-seconds 180]");
+}
+
+const flag = (name) => args.includes(name);
+const option = (name, fallback) => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+async function readConfiguredToken() {
+  if (process.env.JIMENG_TOKEN) return process.env.JIMENG_TOKEN;
+  try {
+    const serialized = await readFile(join(homedir(), ".openclaw", "voc-credentials.json"), "utf8");
+    const credentials = JSON.parse(serialized.replace(/^\uFEFF/, ""));
+    return credentials.jimengToken ?? credentials.jimeng?.token ?? credentials.token;
+  } catch {
+    return undefined;
+  }
+}
+const requestedModes = ["--dry-run", "--prepare-assets", "--execute", "--download-only"].filter(flag);
+if (requestedModes.length > 1) throw new Error("Choose only one mode: --dry-run, --prepare-assets, --execute, or --download-only");
+const mode = flag("--execute") ? "execute" : flag("--prepare-assets") ? "prepare-assets" : flag("--download-only") ? "download-only" : "dry-run";
+if (requestedModes.length === 0) console.warn("No mode supplied; defaulting to dry-run.");
+
+const planFile = resolve(planFileArg);
+const planText = await readFile(planFile, "utf8");
+const plan = JSON.parse(planText);
+if (plan.status !== "ready" || !Array.isArray(plan.images) || plan.images.length !== 7) {
+  throw new Error("Prompt plan must be ready and contain seven images");
+}
+const canonicalPaths = pathsForRun(plan.run_id);
+assertRunFile(planFile, canonicalPaths.run_directory, "prompt-plan.json");
+const execFileAsync = promisify(execFile);
+const validatorFile = fileURLToPath(new URL("../features/image-set/validate-plan.mjs", import.meta.url));
+try {
+  await execFileAsync(process.execPath, [validatorFile, planFile]);
+} catch (error) {
+  let validation = null;
+  try {
+    validation = JSON.parse(error?.stderr || "");
+  } catch {
+    validation = null;
+  }
+  console.log(JSON.stringify({
+    status: "blocked",
+    jimeng_called: false,
+    error: {
+      code: "PLAN_VALIDATION_FAILED",
+      message: validation?.errors?.join("; ") || error?.message || "Prompt plan validation failed",
+    },
+  }, null, 2));
+  process.exit(0);
+}
+
+let visionResult = null;
+let visionResultFile = null;
+let visionResultText = "";
+if (mode === "execute") {
+  const visionResultArg = option("--vision-result", "");
+  if (!visionResultArg) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: { code: "VISION_PIPELINE_REQUIRED", message: "Run the internal product vision adapter before Jimeng execution" },
+    }, null, 2));
+    process.exit(0);
+  }
+  visionResultFile = resolve(visionResultArg);
+  try {
+    assertRunFile(visionResultFile, canonicalPaths.run_directory, "vision-result.json");
+    visionResultText = (await readFile(visionResultFile, "utf8")).replace(/^\uFEFF/, "");
+    visionResult = JSON.parse(visionResultText);
+  } catch (error) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: { code: "VISION_RESULT_INVALID", message: `Cannot read vision result: ${error.message}` },
+    }, null, 2));
+    process.exit(0);
+  }
+  if (visionResult.status !== "complete" || !visionResult.product_understanding || !visionResult.reference?.public_url) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: { code: "VISION_RESULT_INVALID", message: "Vision result is incomplete and cannot authorize Jimeng execution" },
+    }, null, 2));
+    process.exit(0);
+  }
+  const planIdentity = plan.product_understanding?.identity_manifest;
+  const visionIdentity = visionResult.product_understanding?.identity_manifest;
+  const planFacts = plan.product_understanding?.verified_visible_facts;
+  const visionFacts = visionResult.product_understanding?.verified_visible_facts;
+  if (
+    JSON.stringify(planIdentity) !== JSON.stringify(visionIdentity)
+    || JSON.stringify(planFacts) !== JSON.stringify(visionFacts)
+  ) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: {
+        code: "VISION_PLAN_IDENTITY_MISMATCH",
+        message: "The prompt plan changed the audited product identity or verified visible facts",
+      },
+    }, null, 2));
+    process.exit(0);
+  }
+}
+
+const publicUrlPattern = /^https?:\/\//i;
+const costPerImage = 0.22;
+const estimatedCost = Number((plan.images.length * costPerImage).toFixed(2));
+const assetMapFile = assertRunFile(
+  resolve(option("--asset-map", canonicalPaths.asset_map)),
+  canonicalPaths.run_directory,
+  "reference-assets.json",
+);
+const resultOutputFile = assertRunFile(
+  resolve(option("--output", canonicalPaths.jimeng_result)),
+  canonicalPaths.run_directory,
+  "jimeng-result.json",
+);
+const downloadDir = resolve(option("--download-dir", canonicalPaths.images_directory));
+const samePath = (left, right) => process.platform === "win32"
+  ? resolve(left).toLowerCase() === resolve(right).toLowerCase()
+  : resolve(left) === resolve(right);
+if (!samePath(downloadDir, canonicalPaths.images_directory)) {
+  throw new Error(`RUN_PATH_INVALID: images must be downloaded to ${canonicalPaths.images_directory}`);
+}
+const downloadTimeoutSeconds = Math.max(10, Number(option("--download-timeout-seconds", "180")) || 180);
+const forwardPath = (value) => resolve(value).replace(/\\/g, "/");
+const deliveryMetadata = (items) => ({
+  download_dir: downloadDir,
+  local_images: items.map((item) => item.file),
+  image_markdown_links: items.map((item) => `[${item.label}](<${forwardPath(item.file)}>)`),
+  open_folder_command: `explorer "${downloadDir}"`,
+});
+const deliveryItems = (results, acceptedOnly = true) => results.flatMap((entry) => {
+  if (acceptedOnly && entry.status !== "complete") return [];
+  const files = entry.local_images ?? [];
+  return files.map((file, index) => ({
+    slot_id: entry.slot_id,
+    file,
+    label: files.length === 1 ? entry.slot_id : `${entry.slot_id}-${index + 1}`,
+  }));
+});
+
+function imageExtension(url, contentType) {
+  const fromUrl = extname(new URL(url).pathname).toLowerCase();
+  if ([".jpg", ".jpeg", ".png", ".webp"].includes(fromUrl)) return fromUrl;
+  if (contentType?.includes("webp")) return ".webp";
+  if (contentType?.includes("jpeg")) return ".jpg";
+  return ".png";
+}
+
+const localRoleNames = {
+  hero: "白底主图",
+  scene: "场景图",
+  selling_point: "卖点图",
+  decision_support: "购买决策图",
+};
+function localImageStem(slotId) {
+  const image = plan.images.find((item) => item.id === slotId);
+  return `${slotId}-${localRoleNames[image?.business_category] ?? "商品图"}`;
+}
+
+async function downloadImagesForSlot(slotId, imageUrls) {
+  if (!Array.isArray(imageUrls) || imageUrls.length === 0) return [];
+  await mkdir(downloadDir, { recursive: true });
+  const files = [];
+  for (const [index, imageUrl] of imageUrls.entries()) {
+    const response = await fetch(imageUrl, {
+      signal: AbortSignal.timeout(downloadTimeoutSeconds * 1000),
+    });
+    if (!response.ok) throw new Error(`Image download failed: HTTP ${response.status}`);
+    const extension = imageExtension(imageUrl, response.headers.get("content-type"));
+    const suffix = imageUrls.length > 1 ? `-${index + 1}` : "";
+    const target = join(downloadDir, `${localImageStem(slotId)}${suffix}${extension}`);
+    await writeFile(target, Buffer.from(await response.arrayBuffer()));
+    files.push(target);
+  }
+  return files;
+}
+
+if (mode === "download-only") {
+  const rawToken = await readConfiguredToken();
+  if (!rawToken) throw new Error("Jimeng credential is required to verify executor state");
+  const prior = JSON.parse((await readFile(resultOutputFile, "utf8")).replace(/^\uFEFF/, ""));
+  verifyState(prior, rawToken);
+  let downloaded = 0;
+  let failed = 0;
+  for (const entry of prior.results ?? []) {
+    if (!Array.isArray(entry.images) || entry.images.length === 0) continue;
+    try {
+      entry.local_images = await downloadImagesForSlot(entry.slot_id, entry.images);
+      delete entry.download_error;
+      downloaded += entry.local_images.length;
+    } catch (error) {
+      entry.download_error = error.message;
+      failed += 1;
+    }
+  }
+  await writeFile(resultOutputFile, `${JSON.stringify(signState(prior, rawToken), null, 2)}\n`, "utf8");
+  console.log(JSON.stringify({
+    mode,
+    output: resultOutputFile,
+    ...deliveryMetadata(deliveryItems(prior.results ?? [])),
+    downloaded,
+    failed,
+  }, null, 2));
+} else {
+async function existingFile(candidate) {
+  try {
+    const fileStat = await stat(candidate);
+    return fileStat.isFile();
+  } catch {
+    return false;
+  }
+}
+
+async function resolveReference(reference) {
+  if (typeof reference !== "string" || !reference.trim()) throw new Error("Reference image entries must be non-empty strings");
+  const value = reference.trim();
+  if (publicUrlPattern.test(value)) return { kind: "public", source: value, url: value };
+
+  const candidates = isAbsolute(value)
+    ? [resolve(value)]
+    : [resolve(process.cwd(), value), resolve(dirname(planFile), value)];
+  for (const candidate of [...new Set(candidates)]) {
+    if (await existingFile(candidate)) return { kind: "local", source: candidate };
+  }
+  throw new Error(`Reference image was not found locally and is not a public URL: ${value}`);
+}
+
+async function loadAssetMap() {
+  try {
+    const parsed = JSON.parse(await readFile(assetMapFile, "utf8"));
+    return parsed && typeof parsed.assets === "object" ? parsed : { schema_version: "0.1", run_id: plan.run_id, assets: {} };
+  } catch (error) {
+    if (error?.code !== "ENOENT") throw new Error(`Cannot read asset map: ${error.message}`);
+    return { schema_version: "0.1", run_id: plan.run_id, assets: {} };
+  }
+}
+
+async function saveAssetMap(assetMap) {
+  await writeFile(assetMapFile, `${JSON.stringify(assetMap, null, 2)}\n`, "utf8");
+}
+
+const referenceSets = [];
+const uniqueReferences = new Map();
+for (const image of plan.images) {
+  const rawReferences = [...new Set([...(image.reference_images ?? []), ...(plan.request?.reference_images ?? [])])];
+  const resolvedReferences = [];
+  for (const rawReference of rawReferences) {
+    const reference = await resolveReference(rawReference);
+    const key = reference.kind === "public" ? reference.url : reference.source;
+    if (!uniqueReferences.has(key)) uniqueReferences.set(key, reference);
+    resolvedReferences.push(uniqueReferences.get(key));
+  }
+  referenceSets.push(resolvedReferences);
+}
+
+const assetMap = await loadAssetMap();
+if (visionResult?.reference?.kind === "local" && visionResult.reference.source && visionResult.reference.public_url) {
+  const source = resolve(visionResult.reference.source);
+  assetMap.assets[source] = {
+    ...(visionResult.reference.asset ?? {}),
+    source,
+    url: visionResult.reference.public_url,
+  };
+  await saveAssetMap(assetMap);
+}
+const assetUploads = [];
+const urlBySource = new Map();
+for (const reference of uniqueReferences.values()) {
+  if (reference.kind === "public") {
+    urlBySource.set(reference.source, reference.url);
+    continue;
+  }
+  const cached = assetMap.assets[reference.source];
+  if (cached && publicUrlPattern.test(cached.url ?? "")) {
+    urlBySource.set(reference.source, cached.url);
+    assetUploads.push({ ...cached, reused: true });
+  }
+}
+
+async function prepareLocalAssets() {
+  for (const reference of uniqueReferences.values()) {
+    if (reference.kind !== "local" || urlBySource.has(reference.source)) continue;
+    const uploaded = await uploadLocalImage(reference.source);
+    const safeRecord = { ...uploaded, reused: false };
+    assetMap.assets[reference.source] = uploaded;
+    urlBySource.set(reference.source, uploaded.url);
+    assetUploads.push(safeRecord);
+    await saveAssetMap(assetMap);
+  }
+}
+
+function buildTasks() {
+  return plan.images.map((image, index) => {
+    const references = referenceSets[index];
+    const imageUrls = [...new Set(references.map((reference) => urlBySource.get(reference.source)).filter(Boolean))];
+    const pendingLocal = references.some((reference) => reference.kind === "local" && !urlBySource.has(reference.source));
+    const negative = image.negative_prompt?.trim();
+    const prompt = negative ? `${image.generation_prompt}\n禁止:${negative}` : image.generation_prompt;
+    if ([...prompt].length > 800) throw new Error(`Slot ${image.id} final prompt exceeds 800 characters`);
+    return {
+      slot_id: image.id,
+      endpoint: "https://server.fmode.cn/api/volcengine/jimeng/getImgV4",
+      router_name: "getImgV4",
+      reference_status: imageUrls.length ? "ready" : pendingLocal ? "local_path_pending_qiniu_upload" : "missing_reference_image",
+      local_reference_images: references.filter((reference) => reference.kind === "local").map((reference) => reference.source),
+      payload: {
+        prompt,
+        image_urls: imageUrls,
+        sizeDate: { width: image.model_parameters.width, height: image.model_parameters.height },
+        scale: image.model_parameters.scale,
+        force_single: true,
+        client_request_id: `${plan.run_id}:${image.id}`,
+      },
+      estimated_cost_cny: costPerImage,
+    };
+  });
+}
+
+if (mode === "dry-run") {
+  console.log(JSON.stringify({ mode, run_id: plan.run_id, estimated_cost_cny: estimatedCost, will_upload_local_assets: true, tasks: buildTasks() }, null, 2));
+  process.exit(0);
+}
+
+let token;
+let stateSecret;
+if (mode === "execute") {
+  const maxCost = Number(option("--max-cost", "0"));
+  if (!Number.isFinite(maxCost) || maxCost < estimatedCost) throw new Error(`--max-cost must be at least ${estimatedCost}`);
+  const rawToken = await readConfiguredToken();
+  if (!rawToken) throw new Error("Jimeng credential is missing; set JIMENG_TOKEN or configure ~/.openclaw/voc-credentials.json");
+  stateSecret = rawToken;
+  token = rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
+}
+
+await prepareLocalAssets();
+const tasks = buildTasks();
+if (tasks.some((task) => task.payload.image_urls.length === 0)) {
+  throw new Error("All seven slots require at least one product reference image; local uploads must succeed before Jimeng is called");
+}
+
+if (mode === "prepare-assets") {
+  const summary = {
+    mode,
+    run_id: plan.run_id,
+    asset_map: assetMapFile,
+    unique_local_assets: [...uniqueReferences.values()].filter((reference) => reference.kind === "local").length,
+    uploaded: assetUploads.filter((item) => !item.reused).length,
+    reused: assetUploads.filter((item) => item.reused).length,
+    assets: assetUploads,
+    jimeng_called: false,
+  };
+  const outputFile = option("--output", "");
+  if (outputFile) await writeFile(resolve(outputFile), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
+  console.log(JSON.stringify(summary, null, 2));
+}
+
+if (mode === "execute") {
+  const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
+  const pollSeconds = Math.max(0.05, Number(option("--poll-seconds", "4")) || 4);
+  const timeoutSeconds = Math.max(0.1, Number(option("--timeout-seconds", "240")) || 240);
+  const safeRequestAttempts = Math.max(1, Number(option("--request-attempts", "5")) || 5);
+  const submitResponseAttempts = Math.max(1, Number(option("--submit-attempts", "3")) || 3);
+  const unknownCooldownSeconds = Math.max(0, Number(option("--unknown-cooldown-seconds", "30")) || 0);
+  const preflightAttempts = Math.max(1, Number(option("--preflight-attempts", "5")) || 5);
+  const outputFile = resolve(option("--output", `jimeng-result-${plan.run_id}.json`));
+  const submitUrl = process.env.JIMENG_SUBMIT_URL || "https://server.fmode.cn/api/volcengine/jimeng/getImgV4";
+  const queryUrl = process.env.JIMENG_QUERY_URL || "https://server.fmode.cn/api/volcengine/jimeng/getDataByTask02";
+  const resultBaseUrl = process.env.JIMENG_RESULT_BASE_URL || "https://server.fmode.cn/parse/classes/ImagineWork";
+  const preflightUrl = option(
+    "--preflight-url",
+    process.env.JIMENG_PREFLIGHT_URL
+      || (new URL(submitUrl).hostname === "server.fmode.cn"
+        ? `${new URL(submitUrl).origin}/parse/classes/ImagineWork/connection-preflight`
+        : ""),
+  );
+  const qualityConfig = await loadVisionConfig();
+  if (!qualityConfig.apiKey) {
+    console.log(JSON.stringify({
+      status: "blocked",
+      jimeng_called: false,
+      error: {
+        code: "QUALITY_REVIEW_NOT_CONFIGURED",
+        message: "The configured vision provider is required for generated-product identity review",
+      },
+    }, null, 2));
+    process.exit(0);
+  }
+
+  const describeError = (error) => [error?.message, error?.cause?.code].filter(Boolean).join(" ") || "unknown error";
+
+  async function readJsonResponse(response) {
+    const text = await response.text();
+    let result;
+    try {
+      result = text ? JSON.parse(text) : {};
+    } catch {
+      result = {};
+    }
+    if (!response.ok || (result.code !== undefined && result.code !== 200)) {
+      const error = new Error(result.msg ?? result.message ?? `HTTP ${response.status}`);
+      error.status = response.status;
+      throw error;
+    }
+    return result;
+  }
+
+  async function postJson(url, body) {
+    const response = await fetch(url, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(30_000),
+    });
+    return readJsonResponse(response);
+  }
+
+  async function getResult(workId) {
+    const response = await fetch(`${resultBaseUrl}/${encodeURIComponent(workId)}`, {
+      headers: { "X-Parse-Application-Id": "ncloudmaster" },
+      signal: AbortSignal.timeout(30_000),
+    });
+    return readJsonResponse(response);
+  }
+
+  async function retrySafe(operation) {
+    let lastError;
+    for (let attempt = 1; attempt <= safeRequestAttempts; attempt += 1) {
+      try {
+        return await operation();
+      } catch (error) {
+        lastError = error;
+        if (attempt < safeRequestAttempts) await sleep(Math.min(8_000, 1_000 * (2 ** (attempt - 1))));
+      }
+    }
+    throw lastError;
+  }
+
+  async function loadRunState() {
+    let existing;
+    try {
+      existing = JSON.parse((await readFile(outputFile, "utf8")).replace(/^\uFEFF/, ""));
+      verifyState(existing, stateSecret);
+      if (existing.run_id !== plan.run_id) throw new Error("RUN_STATE_PLAN_MISMATCH: run_id changed");
+      if (existing.plan_sha256 !== sha256(planText)) throw new Error("RUN_STATE_PLAN_MISMATCH: prompt-plan.json changed after execution started");
+      if (existing.vision_sha256 !== sha256(visionResultText)) throw new Error("RUN_STATE_VISION_MISMATCH: vision-result.json changed after execution started");
+    } catch (error) {
+      if (error?.code !== "ENOENT") throw new Error(`Cannot read run state: ${error.message}`);
+    }
+    const previous = existing?.run_id === plan.run_id && Array.isArray(existing.results)
+      ? new Map(existing.results.map((item) => [item.slot_id, item]))
+      : new Map();
+    return {
+      schema_version: "0.3",
+      run_id: plan.run_id,
+      adapter: "jimeng_img_v4",
+      status: "running",
+      estimated_cost_cny: estimatedCost,
+      asset_uploads: assetUploads,
+      vision_result: visionResultFile,
+      plan_sha256: sha256(planText),
+      vision_sha256: sha256(visionResultText),
+      started_at: existing?.started_at || new Date().toISOString(),
+      updated_at: new Date().toISOString(),
+      issues: Array.isArray(existing?.issues) ? existing.issues : [],
+      results: tasks.map((task) => ({
+        slot_id: task.slot_id,
+        client_request_id: `${plan.run_id}:${task.slot_id}`,
+        status: "not_submitted",
+        work_id: null,
+        images: [],
+        cost_cny: task.estimated_cost_cny,
+        error: null,
+        ...(previous.get(task.slot_id) ?? {}),
+      })),
+    };
+  }
+
+  const runState = await loadRunState();
+  const entryBySlot = new Map(runState.results.map((item) => [item.slot_id, item]));
+  async function saveRunState() {
+    runState.updated_at = new Date().toISOString();
+    const signed = signState(runState, stateSecret);
+    runState.state_integrity = signed.state_integrity;
+    await writeFile(outputFile, `${JSON.stringify(signed, null, 2)}\n`, "utf8");
+  }
+  async function materializeCompletedImages(entry) {
+    if (!Array.isArray(entry.images) || entry.images.length === 0 || entry.local_images?.length === entry.images.length) return;
+    entry.local_images = await retrySafe(() => downloadImagesForSlot(entry.slot_id, entry.images));
+    delete entry.download_error;
+  }
+  await saveRunState();
+
+  async function recordIssue(entry, status, error) {
+    entry.status = status;
+    entry.error = describeError(error);
+    const issue = { slot_id: entry.slot_id, code: status, message: entry.error, at: new Date().toISOString() };
+    runState.issues.push(issue);
+    await saveRunState();
+    console.error(`[${entry.slot_id}] ${status}: ${entry.error}`);
+    return issue;
+  }
+
+  function isRetryableSubmit(error) {
+    return error?.status === 429 || (error?.status >= 500 && error?.status <= 599);
+  }
+
+  async function ensureSubmitTransportReady(entry) {
+    if (!preflightUrl) return true;
+    let lastError;
+    for (let attempt = 1; attempt <= preflightAttempts; attempt += 1) {
+      entry.preflight_attempts = (entry.preflight_attempts ?? 0) + 1;
+      await saveRunState();
+      try {
+        const response = await fetch(preflightUrl, {
+          method: "GET",
+          headers: { "X-Parse-Application-Id": "ncloudmaster" },
+          signal: AbortSignal.timeout(15_000),
+        });
+        await response.arrayBuffer();
+        entry.last_preflight_at = new Date().toISOString();
+        delete entry.preflight_error;
+        await saveRunState();
+        return true;
+      } catch (error) {
+        lastError = error;
+        entry.preflight_error = describeError(error);
+        await saveRunState();
+        if (attempt < preflightAttempts) {
+          await sleep(Math.min(8_000, 500 * (2 ** (attempt - 1))));
+        }
+      }
+    }
+    await recordIssue(entry, "transport_unavailable", lastError ?? new Error("Jimeng transport preflight failed"));
+    return false;
+  }
+
+  async function submitOne(task, entry) {
+    if (!(await ensureSubmitTransportReady(entry))) return false;
+    for (let attempt = 1; attempt <= submitResponseAttempts; attempt += 1) {
+      entry.submit_attempts = (entry.submit_attempts ?? 0) + 1;
+      await saveRunState();
+      try {
+        const submitted = await postJson(submitUrl, { ...task.payload, token });
+        const workId = submitted.data?.workId;
+        if (!workId) throw new Error(`Slot ${task.slot_id} returned no workId; submission acceptance is unknown`);
+        entry.work_id = workId;
+        entry.status = "submitted";
+        entry.error = null;
+        await saveRunState();
+        console.error(`[${task.slot_id}] submitted workId=${workId}`);
+        return true;
+      } catch (error) {
+        if (!error?.status) {
+          entry.uncertain_submit_attempts = (entry.uncertain_submit_attempts ?? 0) + 1;
+          await saveRunState();
+          await recordIssue(entry, "submission_unknown", error);
+          if (unknownCooldownSeconds > 0) {
+            console.error(`[${entry.slot_id}] acceptance is unknown; waiting ${unknownCooldownSeconds}s before skipping this slot`);
+            await sleep(unknownCooldownSeconds * 1000);
+          }
+          return false;
+        }
+        if (!isRetryableSubmit(error) || attempt === submitResponseAttempts) {
+          await recordIssue(entry, "submit_failed", error);
+          return false;
+        }
+        console.error(`[${task.slot_id}] submit attempt ${attempt} failed with HTTP ${error.status}; retrying`);
+        await sleep(Math.min(8_000, 1_000 * (2 ** (attempt - 1))));
+      }
+    }
+    return false;
+  }
+
+  async function waitAndDownload(entry) {
+    const deadline = Date.now() + timeoutSeconds * 1000;
+    let transientFailures = 0;
+    while (Date.now() < deadline) {
+      try {
+        const direct = await retrySafe(() => getResult(entry.work_id));
+        if (Array.isArray(direct.images) && direct.images.length > 0) {
+          entry.images = direct.images;
+          entry.status = "downloading";
+          entry.error = null;
+          await saveRunState();
+          try {
+            await materializeCompletedImages(entry);
+          } catch (error) {
+            await recordIssue(entry, "download_failed", error);
+            return false;
+          }
+          entry.status = "quality_review";
+          await saveRunState();
+          try {
+            const reviews = [];
+            for (const generatedUrl of entry.images) {
+              reviews.push(await retrySafe(() => reviewGeneratedProduct({
+                referenceUrl: visionResult.reference.public_url,
+                generatedUrl,
+                identityManifest: visionResult.product_understanding.identity_manifest,
+                config: qualityConfig,
+              })));
+            }
+            entry.quality_review = {
+              accepted: reviews.every((review) => review.accepted),
+              reviews,
+              reviewed_at: new Date().toISOString(),
+            };
+            if (!entry.quality_review.accepted) {
+              const violations = reviews.flatMap((review) => review.critical_violations ?? []);
+              await recordIssue(entry, "quality_rejected", new Error(
+                violations.map((item) => `${item.code}: ${item.detail}`).join("; ") || "Generated product no longer matches the reference",
+              ));
+              return false;
+            }
+          } catch (error) {
+            await recordIssue(entry, "quality_review_failed", error);
+            return false;
+          }
+          entry.status = "complete";
+          await saveRunState();
+          console.error(`[${entry.slot_id}] complete and downloaded`);
+          return true;
+        }
+        const state = await retrySafe(() => postJson(queryUrl, {
+          workId: entry.work_id,
+          routerName: "getImgV4",
+          token,
+        }));
+        entry.status = "submitted";
+        entry.error = null;
+        transientFailures = 0;
+        await saveRunState();
+        console.error(`[${entry.slot_id}] ${state.data?.tip ?? "pending"}`);
+      } catch (error) {
+        transientFailures += 1;
+        entry.transient_query_failures = (entry.transient_query_failures ?? 0) + 1;
+        entry.last_transient_error = describeError(error);
+        await saveRunState();
+        const delay = Math.min(15_000, 1_000 * (2 ** Math.min(transientFailures - 1, 4)));
+        console.error(`[${entry.slot_id}] transient query failure; retrying within slot timeout: ${entry.last_transient_error}`);
+        await sleep(delay);
+        continue;
+      }
+      await sleep(pollSeconds * 1000);
+    }
+    await recordIssue(entry, "poll_timeout", new Error(`Slot ${entry.slot_id} did not complete within ${timeoutSeconds}s; workId preserved`));
+    return false;
+  }
+
+  // Normal path is strictly serial: submit, wait, download, then move to the next slot.
+  // A failed/unknown/timed-out slot is isolated and skipped so later slots can still finish.
+  // Unknown submissions are never resubmitted because the remote service has no idempotency key.
+  for (const task of tasks) {
+    const entry = entryBySlot.get(task.slot_id);
+    if (entry.status === "complete" && entry.quality_review?.accepted) {
+      try {
+        await materializeCompletedImages(entry);
+        await saveRunState();
+      } catch (error) {
+        await recordIssue(entry, "download_failed", error);
+      }
+      continue;
+    }
+    if (entry.status === "submission_unknown" || entry.status === "quality_rejected") {
+      console.error(entry.status === "submission_unknown"
+        ? `[${entry.slot_id}] skipped: prior submission acceptance is unknown and will not be resubmitted`
+        : `[${entry.slot_id}] skipped: generated image failed product-identity review and is not auto-regenerated`);
+      continue;
+    }
+    if (!entry.work_id && !(await submitOne(task, entry))) continue;
+    await waitAndDownload(entry);
+  }
+
+  const completed = runState.results.filter((item) => item.status === "complete").length;
+  const pending = runState.results.filter((item) => item.work_id
+    && !["complete", "quality_rejected"].includes(item.status)).length;
+  const failedStatuses = new Set([
+    "submission_unknown",
+    "submit_failed",
+    "download_failed",
+    "poll_timeout",
+    "quality_review_failed",
+    "quality_rejected",
+    "transport_unavailable",
+  ]);
+  const failedEntries = runState.results.filter((item) => failedStatuses.has(item.status));
+  const failed = failedEntries.length;
+  const unknownSlots = runState.results.filter((item) => item.status === "submission_unknown").map((item) => item.slot_id);
+  const resumableSlots = runState.results
+    .filter((item) => !["complete", "submission_unknown", "quality_rejected"].includes(item.status))
+    .map((item) => item.slot_id);
+  const rejectedSlots = runState.results
+    .filter((item) => item.status === "quality_rejected")
+    .map((item) => item.slot_id);
+  const confirmedSubmitted = runState.results.filter((item) => item.work_id).length;
+  const potentialUnknown = unknownSlots.length;
+  const confirmedMinimumCost = Number((confirmedSubmitted * costPerImage).toFixed(2));
+  const potentialAdditionalCost = Number((potentialUnknown * costPerImage).toFixed(2));
+  const costRange = {
+    min: confirmedMinimumCost,
+    max: Number((confirmedMinimumCost + potentialAdditionalCost).toFixed(2)),
+  };
+  runState.status = completed === 7 ? "complete" : "partial";
+  runState.summary = {
+    completed,
+    failed,
+    unknown_slots: unknownSlots,
+    rejected_slots: rejectedSlots,
+    resumable_slots: resumableSlots,
+    cost_range_cny: costRange,
+  };
+  await saveRunState();
+  const resumable = resumableSlots.length > 0;
+  const nextAction = runState.status === "complete"
+    ? "deliver_local_images"
+    : resumable
+      ? "deliver_partial_then_resume_same_plan_vision_and_output"
+      : rejectedSlots.length
+        ? "deliver_accepted_images_and_report_quality_rejections_without_auto_regeneration"
+        : "deliver_partial_and_report_unknown_do_not_resubmit";
+  const completedItems = deliveryItems(runState.results);
+  const rejectedItems = deliveryItems(
+    runState.results.filter((item) => item.status === "quality_rejected"),
+    false,
+  );
+  console.log(JSON.stringify({
+    output: outputFile,
+    ...deliveryMetadata(completedItems),
+    status: runState.status,
+    completed,
+    pending,
+    failed,
+    failed_slots: failedEntries.map((item) => ({ slot_id: item.slot_id, code: item.status, message: item.error })),
+    unknown_slots: unknownSlots,
+    rejected_slots: rejectedSlots,
+    rejected_local_images: rejectedItems.map((item) => item.file),
+    resumable_slots: resumableSlots,
+    resumable,
+    confirmed_minimum_cost_cny: confirmedMinimumCost,
+    potential_additional_cost_cny: potentialAdditionalCost,
+    cost_range_cny: costRange,
+    next_action: nextAction,
+  }, null, 2));
+}
+}

+ 147 - 0
claude-code/fmode-image-set/mcp/src/providers/jimeng-v4-client.test.mjs

@@ -0,0 +1,147 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createServer } from "node:http";
+import { readFile, rm, writeFile } from "node:fs/promises";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { buildValidPlan } from "../../../scripts/test-plan-fixture.mjs";
+import { createRun } from "../core/run-paths.mjs";
+import { verifyState } from "../core/run-state-integrity.mjs";
+
+const execFileAsync = promisify(execFile);
+const executorFile = fileURLToPath(new URL("./jimeng-v4-client.mjs", import.meta.url));
+
+function slotFromPayload(payload) {
+  return payload.client_request_id?.split(":").pop()
+    ?? payload.prompt.match(/Slot\s+(\d+)/i)?.[1]?.padStart(2, "0")
+    ?? "unknown";
+}
+
+test("a definite slot failure is isolated and later slots still complete", async () => {
+  const submissions = new Map();
+  let preflightCalls = 0;
+  const server = createServer(async (request, response) => {
+    const base = `http://127.0.0.1:${server.address().port}`;
+    if (request.method === "GET" && request.url === "/preflight") {
+      preflightCalls += 1;
+      if (preflightCalls <= 2) {
+        request.socket.destroy();
+        return;
+      }
+      response.writeHead(204);
+      response.end();
+      return;
+    }
+    if (request.method === "POST" && request.url === "/submit") {
+      let body = "";
+      for await (const chunk of request) body += chunk;
+      const slot = slotFromPayload(JSON.parse(body));
+      submissions.set(slot, (submissions.get(slot) ?? 0) + 1);
+      if (slot === "02" && submissions.get(slot) === 1) {
+        response.writeHead(400, { "Content-Type": "application/json" });
+        response.end(JSON.stringify({ code: 400, msg: "definite test rejection" }));
+        return;
+      }
+      response.writeHead(200, { "Content-Type": "application/json" });
+      response.end(JSON.stringify({ code: 200, data: { workId: `work-${slot}` } }));
+      return;
+    }
+    if (request.method === "GET" && request.url?.startsWith("/result/work-")) {
+      const slot = request.url.split("work-")[1];
+      response.writeHead(200, { "Content-Type": "application/json" });
+      response.end(JSON.stringify({ images: [`${base}/image/${slot}.png`] }));
+      return;
+    }
+    if (request.method === "GET" && request.url?.startsWith("/image/")) {
+      response.writeHead(200, { "Content-Type": "image/png" });
+      response.end(Buffer.from("test-image"));
+      return;
+    }
+    if (request.method === "POST" && request.url === "/vision/chat/completions") {
+      response.writeHead(200, { "Content-Type": "application/json" });
+      response.end(JSON.stringify({
+        choices: [{ message: { content: JSON.stringify({
+          same_product: true,
+          confidence: "high",
+          preserved_features: ["shape", "color", "visible structure"],
+          critical_violations: [],
+          notes: "accepted",
+        }) } }],
+      }));
+      return;
+    }
+    response.writeHead(404);
+    response.end();
+  });
+
+  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+  const paths = await createRun();
+  const secret = "test-jimeng-token";
+  try {
+    const reference = "https://images.example.test/product.jpg";
+    const plan = buildValidPlan("generic consumer product", reference, paths.run_id);
+    await writeFile(paths.prompt_plan, `${JSON.stringify(plan, null, 2)}\n`);
+    await writeFile(paths.vision_result, `${JSON.stringify({
+      status: "complete",
+      product_understanding: plan.product_understanding,
+      reference: { kind: "public", source: reference, public_url: reference },
+    }, null, 2)}\n`);
+
+    const base = `http://127.0.0.1:${server.address().port}`;
+    const run = () => execFileAsync(process.execPath, [
+      executorFile,
+      paths.prompt_plan,
+      "--execute",
+      "--vision-result", paths.vision_result,
+      "--asset-map", paths.asset_map,
+      "--output", paths.jimeng_result,
+      "--max-cost", "1.54",
+      "--poll-seconds", "0.05",
+      "--timeout-seconds", "1",
+      "--unknown-cooldown-seconds", "0",
+    ], {
+      env: {
+        ...process.env,
+        JIMENG_TOKEN: secret,
+        JIMENG_SUBMIT_URL: `${base}/submit`,
+        JIMENG_QUERY_URL: `${base}/query`,
+        JIMENG_RESULT_BASE_URL: `${base}/result`,
+        JIMENG_PREFLIGHT_URL: `${base}/preflight`,
+        COMMERCE_VISION_API_KEY: "test-key",
+        COMMERCE_VISION_BASE_URL: `${base}/vision`,
+      },
+    });
+
+    const first = JSON.parse((await run()).stdout);
+    assert.equal(first.status, "partial");
+    assert.equal(first.completed, 6);
+    assert.deepEqual(first.failed_slots.map((item) => item.slot_id), ["02"]);
+    assert.equal(submissions.get("03"), 1, "slot 03 must run after slot 02 fails");
+    assert.equal(preflightCalls >= 9, true, "network resets must be absorbed by the non-billing preflight");
+    assert.equal(first.unknown_slots.length, 0, "preflight resets must not create unknown paid submissions");
+    assert.equal(first.download_dir, paths.images_directory);
+    assert.ok(first.image_markdown_links.some((item) => item.startsWith("[03]")));
+    assert.equal(first.image_markdown_links.some((item) => item.startsWith("[02]")), false);
+
+    const second = JSON.parse((await run()).stdout);
+    assert.equal(second.status, "complete");
+    assert.equal(second.completed, 7);
+    assert.equal(submissions.get("01"), 1, "completed slots must not be resubmitted");
+    assert.equal(submissions.get("02"), 2, "only the definitely failed slot is retried");
+
+    const state = JSON.parse(await readFile(paths.jimeng_result, "utf8"));
+    assert.equal(verifyState(state, secret), true);
+    assert.equal(state.results[0].client_request_id, `${paths.run_id}:01`);
+    state.results[0].submit_attempts = 0;
+    await writeFile(paths.jimeng_result, `${JSON.stringify(state, null, 2)}\n`);
+    await assert.rejects(run(), (error) => {
+      assert.match(error.stderr, /RUN_STATE_TAMPERED/);
+      return true;
+    });
+  } finally {
+    await new Promise((resolve) => server.close(resolve));
+    assert.ok(paths.run_directory.startsWith(paths.runs_root));
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});

+ 169 - 0
claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.mjs

@@ -0,0 +1,169 @@
+import { createHmac, randomBytes } from "node:crypto";
+import { readFile, stat, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { basename, extname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const DEFAULT_CREDENTIALS_FILE = resolve(homedir(), ".openclaw", "voc-credentials.json");
+const MAX_IMAGE_BYTES = 30 * 1024 * 1024;
+const MIME_BY_EXTENSION = new Map([
+  [".jpg", "image/jpeg"],
+  [".jpeg", "image/jpeg"],
+  [".png", "image/png"],
+  [".webp", "image/webp"],
+  [".gif", "image/gif"],
+]);
+
+const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
+const stripTrailingSlash = (value) => String(value || "").replace(/\/+$/, "");
+const stripBoundarySlashes = (value) => String(value || "").replace(/^\/+|\/+$/g, "");
+
+function requireValue(value, label) {
+  if (!String(value || "").trim()) throw new Error(`${label} is not configured`);
+  return String(value).trim();
+}
+
+export async function loadQiniuConfig({ env = process.env, credentialsFile = DEFAULT_CREDENTIALS_FILE } = {}) {
+  let stored = {};
+  try {
+    const serialized = await readFile(credentialsFile, "utf8");
+    const parsed = JSON.parse(serialized.replace(/^\uFEFF/, ""));
+    stored = parsed.qiniu ?? {};
+  } catch (error) {
+    if (error?.code !== "ENOENT") throw new Error(`Cannot read Qiniu credentials: ${error.message}`);
+  }
+
+  return {
+    accessKey: requireValue(env.QINIU_AK || env.QINIU_ACCESS_KEY || stored.accessKey, "Qiniu access key"),
+    secretKey: requireValue(env.QINIU_SK || env.QINIU_SECRET_KEY || stored.secretKey, "Qiniu secret key"),
+    bucket: requireValue(env.QINIU_BUCKET || stored.bucket, "Qiniu bucket"),
+    cdnDomain: stripTrailingSlash(requireValue(env.QINIU_CDN_DOMAIN || stored.cdnDomain, "Qiniu CDN domain")),
+    prefix: stripBoundarySlashes(env.QINIU_CDN_PREFIX || stored.prefix || "x/openclaw-skills/commerce-image-prompts"),
+    uploadUrl: stripTrailingSlash(requireValue(env.QINIU_UPLOAD_URL || stored.uploadUrl, "Qiniu upload URL")),
+  };
+}
+
+function base64Url(value) {
+  return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
+}
+
+export function createUploadToken(config, key, deadline = Math.floor(Date.now() / 1000) + 3600) {
+  const encodedPolicy = base64Url(JSON.stringify({ scope: `${config.bucket}:${key}`, deadline }));
+  const signature = createHmac("sha1", config.secretKey).update(encodedPolicy).digest("base64").replace(/\+/g, "-").replace(/\//g, "_");
+  return `${config.accessKey}:${signature}:${encodedPolicy}`;
+}
+
+function buildObjectKey(config, localPath, now = new Date()) {
+  const extension = extname(localPath).toLowerCase();
+  const originalBase = basename(localPath, extension);
+  const safeBase = originalBase.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_").slice(0, 80) || "product";
+  const yyyy = String(now.getFullYear());
+  const mm = String(now.getMonth() + 1).padStart(2, "0");
+  const dd = String(now.getDate()).padStart(2, "0");
+  const unique = `${now.getTime()}-${randomBytes(4).toString("hex")}`;
+  return `${config.prefix}/${yyyy}/${mm}/${yyyy}${mm}${dd}-${unique}-${safeBase}${extension}`;
+}
+
+async function parseUploadResponse(response) {
+  const text = await response.text();
+  let payload = {};
+  try {
+    payload = text ? JSON.parse(text) : {};
+  } catch {
+    payload = {};
+  }
+  if (!response.ok) {
+    throw new Error(payload.error || payload.message || text || `Qiniu upload failed: HTTP ${response.status}`);
+  }
+  return payload;
+}
+
+export async function uploadLocalImage(localPath, {
+  config,
+  fetchImpl = globalThis.fetch,
+  maxAttempts = 3,
+  now = new Date(),
+} = {}) {
+  if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable in this Node runtime");
+  const absolutePath = resolve(localPath);
+  const fileStat = await stat(absolutePath);
+  if (!fileStat.isFile()) throw new Error(`Reference image is not a file: ${absolutePath}`);
+  if (fileStat.size <= 0) throw new Error(`Reference image is empty: ${absolutePath}`);
+  if (fileStat.size > MAX_IMAGE_BYTES) throw new Error(`Reference image exceeds 30 MB: ${absolutePath}`);
+
+  const extension = extname(absolutePath).toLowerCase();
+  const mimeType = MIME_BY_EXTENSION.get(extension);
+  if (!mimeType) throw new Error(`Unsupported reference image type: ${extension || "unknown"}`);
+
+  const resolvedConfig = config ?? await loadQiniuConfig();
+  const key = buildObjectKey(resolvedConfig, absolutePath, now);
+  const uploadToken = createUploadToken(resolvedConfig, key);
+  const bytes = await readFile(absolutePath);
+  let lastError;
+
+  for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt += 1) {
+    try {
+      const form = new FormData();
+      form.append("token", uploadToken);
+      form.append("key", key);
+      form.append("file", new Blob([bytes], { type: mimeType }), basename(absolutePath));
+      const response = await fetchImpl(resolvedConfig.uploadUrl, { method: "POST", body: form });
+      if (response.status >= 500 && attempt < maxAttempts) {
+        await response.body?.cancel?.();
+        await sleep(500 * attempt);
+        continue;
+      }
+      const payload = await parseUploadResponse(response);
+      const uploadedKey = String(payload.key || key);
+      return {
+        source: absolutePath,
+        url: `${resolvedConfig.cdnDomain}/${uploadedKey}`,
+        key: uploadedKey,
+        mime_type: mimeType,
+        size_bytes: fileStat.size,
+      };
+    } catch (error) {
+      lastError = error;
+      if (attempt >= maxAttempts) break;
+      await sleep(500 * attempt);
+    }
+  }
+
+  throw new Error(`Qiniu upload failed for ${basename(absolutePath)}: ${lastError?.message || "unknown error"}`);
+}
+
+async function cli() {
+  const args = process.argv.slice(2);
+  const localPath = args.find((value) => !value.startsWith("--"));
+  if (!localPath) throw new Error("Usage: node qiniu-upload-client.mjs <local-image> [--dry-run] [--output result.json]");
+  const outputIndex = args.indexOf("--output");
+  const outputFile = outputIndex >= 0 ? args[outputIndex + 1] : "";
+  const absolutePath = resolve(localPath);
+  const fileStat = await stat(absolutePath);
+
+  let result;
+  if (args.includes("--dry-run")) {
+    const extension = extname(absolutePath).toLowerCase();
+    result = {
+      mode: "dry-run",
+      source: absolutePath,
+      size_bytes: fileStat.size,
+      mime_type: MIME_BY_EXTENSION.get(extension) || "unsupported",
+      would_upload: true,
+    };
+  } else {
+    result = { mode: "uploaded", ...(await uploadLocalImage(absolutePath)) };
+  }
+
+  const serialized = `${JSON.stringify(result, null, 2)}\n`;
+  if (outputFile) await writeFile(outputFile, serialized, "utf8");
+  process.stdout.write(serialized);
+}
+
+const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
+if (isDirectExecution) {
+  cli().catch((error) => {
+    console.error(error.message);
+    process.exitCode = 1;
+  });
+}

+ 524 - 0
claude-code/fmode-image-set/mcp/src/providers/qiniu-upload-client.test.mjs

@@ -0,0 +1,524 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { once } from "node:events";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { createUploadToken, loadQiniuConfig, uploadLocalImage } from "./qiniu-upload-client.mjs";
+import { buildValidPlan } from "../../../scripts/test-plan-fixture.mjs";
+import { createRun } from "../core/run-paths.mjs";
+
+const execFileAsync = promisify(execFile);
+const clientFile = fileURLToPath(new URL("./jimeng-v4-client.mjs", import.meta.url));
+const visionClientFile = fileURLToPath(new URL("../features/image-set/product-vision-client.mjs", import.meta.url));
+const visionResultForPlan = (plan, reference) => ({
+  status: "complete",
+  reference: { kind: "public", source: reference, public_url: reference },
+  product_understanding: {
+    category: plan.product_understanding.category,
+    verified_visible_facts: plan.product_understanding.verified_visible_facts,
+    identity_manifest: plan.product_understanding.identity_manifest,
+  },
+});
+
+test("createUploadToken creates a scoped Qiniu token without exposing the secret", () => {
+  const token = createUploadToken({ accessKey: "demo-ak", secretKey: "demo-sk", bucket: "demo-bucket" }, "demo/product.png", 2_000_000_000);
+  const [accessKey, signature, policy] = token.split(":");
+  assert.equal(accessKey, "demo-ak");
+  assert.ok(signature.length > 10);
+  const decoded = JSON.parse(Buffer.from(policy, "base64url").toString("utf8"));
+  assert.deepEqual(decoded, { scope: "demo-bucket:demo/product.png", deadline: 2_000_000_000 });
+  assert.equal(token.includes("demo-sk"), false);
+  assert.equal(signature.endsWith("="), true);
+});
+
+test("loadQiniuConfig accepts a Windows UTF-8 BOM credentials file", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-credentials-test-"));
+  try {
+    const credentialsFile = join(directory, "credentials.json");
+    await writeFile(credentialsFile, `\uFEFF${JSON.stringify({ qiniu: {
+      accessKey: "demo-ak",
+      secretKey: "demo-sk",
+      bucket: "demo-bucket",
+      cdnDomain: "https://cdn.example.test",
+      prefix: "demo",
+      uploadUrl: "https://upload.example.test",
+    } })}`);
+    const config = await loadQiniuConfig({ env: {}, credentialsFile });
+    assert.equal(config.bucket, "demo-bucket");
+    assert.equal(config.prefix, "demo");
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("uploadLocalImage posts one file and returns the configured CDN URL", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "commerce-qiniu-test-"));
+  try {
+    const localImage = join(directory, "product.png");
+    await writeFile(localImage, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
+    let calls = 0;
+    const result = await uploadLocalImage(localImage, {
+      config: {
+        accessKey: "demo-ak",
+        secretKey: "demo-sk",
+        bucket: "demo-bucket",
+        cdnDomain: "https://cdn.example.test",
+        prefix: "demo",
+        uploadUrl: "https://upload.example.test",
+      },
+      now: new Date("2026-07-22T00:00:00Z"),
+      fetchImpl: async (url, options) => {
+        calls += 1;
+        assert.equal(url, "https://upload.example.test");
+        assert.equal(options.method, "POST");
+        assert.ok(options.body.get("token"));
+        assert.match(options.body.get("key"), /^demo\/2026\/07\//);
+        assert.equal(options.body.get("file").type, "image/png");
+        return new Response(JSON.stringify({ key: options.body.get("key") }), {
+          status: 200,
+          headers: { "Content-Type": "application/json" },
+        });
+      },
+    });
+    assert.equal(calls, 1);
+    assert.match(result.url, /^https:\/\/cdn\.example\.test\/demo\/2026\/07\//);
+    assert.equal(result.source, localImage);
+  } finally {
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng dry-run marks local references pending and performs no upload", async () => {
+  const paths = await createRun();
+  try {
+    const localImage = join(paths.run_directory, "product.jpg");
+    const planFile = paths.prompt_plan;
+    const assetMap = paths.asset_map;
+    await writeFile(localImage, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
+    await writeFile(planFile, JSON.stringify(buildValidPlan("test product", localImage, paths.run_id)));
+
+    const { stdout } = await execFileAsync(process.execPath, [clientFile, planFile, "--dry-run", "--asset-map", assetMap]);
+    const result = JSON.parse(stdout);
+    assert.equal(result.mode, "dry-run");
+    assert.equal(result.tasks.length, 7);
+    assert.ok(result.tasks.every((task) => task.reference_status === "local_path_pending_qiniu_upload"));
+    assert.ok(result.tasks.every((task) => task.payload.image_urls.length === 0));
+    await assert.rejects(readFile(assetMap), (error) => error.code === "ENOENT");
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng refuses an invalid business plan even when Claude skips the explicit validator step", async () => {
+  const paths = await createRun();
+  try {
+    const planFile = paths.prompt_plan;
+    const plan = buildValidPlan("test product", "https://images.example.test/product.jpg", paths.run_id);
+    plan.images[6].business_category = "selling_point";
+    plan.images[6].selling_point_id = "visible-sp-1";
+    plan.images[6].evidence_refs = ["visible-sp-1"];
+    plan.images[6].set_design.render_family = "feature_demonstration";
+    await writeFile(planFile, JSON.stringify(plan));
+    const { stdout } = await execFileAsync(process.execPath, [clientFile, planFile, "--dry-run"]);
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "blocked");
+    assert.equal(result.jimeng_called, false);
+    assert.equal(result.error.code, "PLAN_VALIDATION_FAILED");
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng requires the internal vision pipeline result before generation", async () => {
+  const paths = await createRun();
+  try {
+    const planFile = paths.prompt_plan;
+    await writeFile(planFile, JSON.stringify(buildValidPlan(
+      "test product",
+      "https://images.example.test/product.jpg",
+      paths.run_id,
+    )));
+    const { stdout } = await execFileAsync(process.execPath, [clientFile, planFile, "--execute", "--max-cost", "1.54"], {
+      env: { ...process.env, JIMENG_TOKEN: "test-token" },
+    });
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "blocked");
+    assert.equal(result.jimeng_called, false);
+    assert.equal(result.error.code, "VISION_PIPELINE_REQUIRED");
+  } finally {
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("product vision adapter sends the image to an OpenAI-compatible vision model and normalizes JSON", async () => {
+  const paths = await createRun();
+  let receivedBody;
+  const receivedBodies = [];
+  const server = createServer((request, response) => {
+    let body = "";
+    request.on("data", (chunk) => { body += chunk; });
+    request.on("end", () => {
+      receivedBody = JSON.parse(body);
+      receivedBodies.push(receivedBody);
+      if (receivedBodies.length === 2) {
+        response.setHeader("Content-Type", "application/json");
+        response.end(JSON.stringify({
+          choices: [{ message: { content: JSON.stringify({
+            category: "portable consumer product",
+            product_name: { value: "DEMO portable product", basis: "verified visible evidence", confidence: "high" },
+            verified_visible_facts: ["rectangular main silhouette", "black rounded top component"],
+            inferred_attributes: [],
+            unknown_features: ["internal structure", "real dimensions"],
+            verified_selling_points: [
+              { id: "visible-sp-1", value: "black rounded top component contrasts with the body", basis_refs: ["visible_fact:1"], confidence: "high" },
+              { id: "visible-sp-2", value: "rectangular main silhouette is visually clear", basis_refs: ["visible_fact:2"], confidence: "high" },
+            ],
+            verified_specs: { visible_quantity: "1" },
+            identity_manifest: {
+              must_preserve: [
+                "方形磨砂半透明瓶身轮廓",
+                "方形磨砂瓶身轮廓",
+                "白色按压泵头配短直管嘴",
+                "白色按压泵头+短直管嘴",
+                "拉丝银色金属连接环",
+                "银色拉丝金属连接环",
+                "正面中央空白矩形标签位",
+              ],
+              must_not_add: ["extra controls", "extra accessories"],
+              unknown_do_not_assert: ["internal structure", "real dimensions"],
+            },
+            corrections: ["removed unsupported inference"],
+          }) } }],
+          usage: { prompt_tokens: 11, completion_tokens: 21 },
+        }));
+        return;
+      }
+      response.setHeader("Content-Type", "application/json");
+      response.end(JSON.stringify({
+        choices: [{ message: { content: JSON.stringify({
+          category: "便携消费品",
+          product_name: { value: "DEMO便携产品", basis: "可见文字和外观", confidence: "high" },
+          visible_facts: ["矩形主体轮廓", "黑色圆润部件"],
+          visible_text: [{ text: "DEMO", confidence: "high" }],
+          visible_selling_points: [
+            { id: "duplicate-id", value: "矩形轮廓让外观边界清晰", basis_refs: ["visible_fact_99"], confidence: "high" },
+            { id: "duplicate-id", value: "圆润部件与主体形成形态对比", basis_refs: [], confidence: "high" },
+          ],
+          visible_specs: { visible_quantity: "1" },
+          creative_positioning: ["清晰、克制的商业视觉"],
+          suggested_audiences: ["重视外观设计的消费者"],
+          suggested_scenes: ["整洁日常环境"],
+          unsupported_claims: ["真实性能"],
+          product_invariants: ["保持主体和圆润部件结构"],
+          material_and_light_behavior: ["表面具有可见高光"],
+          uncertain_features: ["内部结构不可见"],
+          generation_risks: ["标签文字容易变形"],
+        }) } }],
+        usage: { prompt_tokens: 10, completion_tokens: 20 },
+      }));
+    });
+  });
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const { port } = server.address();
+    const outputFile = paths.vision_result;
+    const env = {
+      ...process.env,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${port}/v1`,
+      COMMERCE_VISION_MODEL: "test-vl",
+    };
+    const { stdout } = await execFileAsync(process.execPath, [
+      visionClientFile,
+      "--image", "https://images.example.test/product.jpg",
+      "--output", outputFile,
+    ], { env });
+    const result = JSON.parse(stdout);
+    assert.equal(result.status, "complete");
+    assert.equal(result.product_understanding.category, "portable consumer product");
+    assert.equal(result.product_understanding.visible_facts.length, 2);
+    assert.equal(result.product_understanding.visible_selling_points.length, 2);
+    assert.deepEqual(result.product_understanding.visible_selling_points[0].basis_refs, ["visible_fact:2"]);
+    assert.deepEqual(result.product_understanding.visible_selling_points[1].basis_refs, ["visible_fact:1"]);
+    assert.equal(receivedBody.model, "test-vl");
+    assert.equal(receivedBody.messages[0].content[0].image_url.url, "https://images.example.test/product.jpg");
+    assert.equal(receivedBodies.length, 2);
+    assert.equal(result.fact_audit_called, true);
+    assert.ok(result.product_understanding.identity_manifest.product_lock.includes("方形磨砂半透明瓶身轮廓"));
+    assert.equal(
+      result.product_understanding.identity_manifest.must_preserve.filter((item) => item.includes("方形磨砂")).length,
+      1,
+    );
+    assert.equal(
+      result.product_understanding.identity_manifest.must_preserve.filter((item) => item.includes("白色按压泵头")).length,
+      1,
+    );
+    const auditInstruction = receivedBodies[1].messages[0].content.at(-1).text;
+    assert.match(auditInstruction, /"id":"visible-sp-1"/);
+    assert.match(auditInstruction, /"basis_refs":\["visible_fact:1"\]/);
+    const repeated = await execFileAsync(process.execPath, [
+      visionClientFile,
+      "--image", "https://images.example.test/product.jpg",
+      "--output", outputFile,
+    ], { env });
+    const repeatedResult = JSON.parse(repeated.stdout);
+    assert.equal(repeatedResult.reused_existing_result, true);
+    assert.equal(repeatedResult.external_calls_made, false);
+    assert.equal(receivedBodies.length, 2, "a completed result must not trigger additional vision calls");
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("a sparse final vision audit is deterministically repaired without repeating external calls", async () => {
+  const paths = await createRun();
+  let calls = 0;
+  const server = createServer(async (request, response) => {
+    for await (const _chunk of request) {
+      // Drain the request before replying.
+    }
+    calls += 1;
+    const content = calls === 1 ? {
+      category: "test product",
+      product_name: { value: "test product", basis: "visible shape", confidence: "high" },
+      visible_facts: ["fact one", "fact two"],
+      visible_text: [],
+      visible_selling_points: [],
+      visible_specs: {},
+      creative_positioning: [],
+      suggested_audiences: [],
+      suggested_scenes: [],
+      unsupported_claims: [],
+      product_invariants: ["shape", "color", "quantity"],
+      material_and_light_behavior: [],
+      uncertain_features: [],
+      generation_risks: [],
+    } : {
+      category: "test product",
+      product_name: { value: "test product", basis: "verified visible evidence", confidence: "high" },
+      verified_visible_facts: ["fact one"],
+      inferred_attributes: [],
+      unknown_features: [],
+      verified_selling_points: [
+        { id: "duplicate-id", value: "only one final point", basis_refs: ["visible_fact:99"], confidence: "high" },
+      ],
+      verified_specs: {},
+      identity_manifest: {
+        must_preserve: ["shape"],
+        must_not_add: [],
+        unknown_do_not_assert: [],
+      },
+      corrections: [],
+    };
+    response.writeHead(200, { "Content-Type": "application/json" });
+    response.end(JSON.stringify({ choices: [{ message: { content: JSON.stringify(content) } }] }));
+  });
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const env = {
+      ...process.env,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${server.address().port}/v1`,
+    };
+    const command = () => execFileAsync(process.execPath, [
+      visionClientFile,
+      "--image", "https://images.example.test/product.jpg",
+      "--output", paths.vision_result,
+    ], { env });
+    const first = JSON.parse((await command()).stdout);
+    assert.equal(first.status, "complete");
+    assert.equal(first.product_understanding.visible_selling_points.length >= 2, true);
+    assert.deepEqual(
+      first.product_understanding.visible_selling_points.map((point) => point.id),
+      first.product_understanding.visible_selling_points.map((_, index) => `visible-sp-${index + 1}`),
+    );
+    assert.equal(
+      first.product_understanding.visible_selling_points.every((point) => point.basis_refs.length > 0),
+      true,
+    );
+    assert.equal(first.product_understanding.identity_manifest.must_preserve.length >= 3, true);
+    assert.equal(calls, 2);
+    const second = JSON.parse((await command()).stdout);
+    assert.equal(second.reused_existing_result, true);
+    assert.equal(second.external_calls_made, false);
+    assert.equal(calls, 2);
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng execution completes and downloads each slot before submitting the next", async () => {
+  const paths = await createRun();
+  let submitCalls = 0;
+  let activeJobs = 0;
+  let maxActiveJobs = 0;
+  let baseUrl = "";
+  const ready = new Set();
+  const completed = new Set();
+  const server = createServer((request, response) => {
+    if (request.method === "GET" && request.url.startsWith("/image/")) {
+      response.setHeader("Content-Type", "image/png");
+      response.end(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
+      return;
+    }
+    response.setHeader("Content-Type", "application/json");
+    if (request.method === "POST" && request.url === "/vision/chat/completions") {
+      response.end(JSON.stringify({
+        choices: [{ message: { content: JSON.stringify({
+          same_product: true,
+          confidence: "high",
+          preserved_features: ["main silhouette", "color", "visible structure"],
+          critical_violations: [],
+          notes: "accepted",
+        }) } }],
+      }));
+      return;
+    }
+    if (request.method === "POST" && request.url === "/submit") {
+      submitCalls += 1;
+      activeJobs += 1;
+      maxActiveJobs = Math.max(maxActiveJobs, activeJobs);
+      response.end(JSON.stringify({ code: 200, data: { workId: `work-${submitCalls}` } }));
+      return;
+    }
+    if (request.method === "POST" && request.url === "/query") {
+      let body = "";
+      request.on("data", (chunk) => { body += chunk; });
+      request.on("end", () => {
+        const { workId } = JSON.parse(body);
+        ready.add(workId);
+        response.end(JSON.stringify({ code: 200, data: { isFinish: true, tip: "complete" } }));
+      });
+      return;
+    }
+    if (request.method === "GET" && request.url.startsWith("/result/")) {
+      const workId = request.url.split("/").pop();
+      if (ready.has(workId) && !completed.has(workId)) {
+        completed.add(workId);
+        activeJobs -= 1;
+      }
+      response.end(JSON.stringify({ images: ready.has(workId) ? [`${baseUrl}/image/${workId}.png`] : [] }));
+      return;
+    }
+    response.statusCode = 404;
+    response.end(JSON.stringify({ message: "not found" }));
+  });
+
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const { port } = server.address();
+    baseUrl = `http://127.0.0.1:${port}`;
+    const planFile = paths.prompt_plan;
+    const outputFile = paths.jimeng_result;
+    const visionFile = paths.vision_result;
+    const plan = buildValidPlan(
+      "test product",
+      "https://images.example.test/product.jpg",
+      paths.run_id,
+    );
+    await writeFile(planFile, JSON.stringify(plan));
+    await writeFile(visionFile, JSON.stringify(visionResultForPlan(plan, "https://images.example.test/product.jpg")));
+    const env = {
+      ...process.env,
+      JIMENG_TOKEN: "test-token",
+      JIMENG_SUBMIT_URL: `http://127.0.0.1:${port}/submit`,
+      JIMENG_QUERY_URL: `http://127.0.0.1:${port}/query`,
+      JIMENG_RESULT_BASE_URL: `http://127.0.0.1:${port}/result`,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${port}/vision`,
+    };
+    const args = [clientFile, planFile, "--execute", "--vision-result", visionFile, "--max-cost", "1.54", "--output", outputFile, "--poll-seconds", "0.05", "--timeout-seconds", "2"];
+    const execution = await execFileAsync(process.execPath, args, { env });
+    const summary = JSON.parse(execution.stdout);
+    assert.equal(summary.status, "complete");
+    assert.equal(summary.local_images.length, 7);
+    assert.ok(summary.local_images.every((item) => item.startsWith(paths.images_directory)));
+    assert.ok(summary.local_images.some((item) => item.endsWith("01-白底主图.png")));
+    assert.ok(summary.local_images.some((item) => item.endsWith("02-卖点图.png")));
+    assert.ok(summary.local_images.some((item) => item.endsWith("03-场景图.png")));
+    assert.ok(summary.local_images.some((item) => item.endsWith("05-购买决策图.png")));
+    assert.equal(summary.download_dir, paths.images_directory);
+    const saved = JSON.parse(await readFile(outputFile, "utf8"));
+    assert.equal(saved.results.filter((item) => item.status === "complete").length, 7);
+    assert.equal(saved.results.filter((item) => item.local_images?.length === 1).length, 7);
+    assert.equal(submitCalls, 7);
+    assert.equal(maxActiveJobs, 1);
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});
+
+test("jimeng retries explicit HTTP failure, never resubmits unknown slots, and continues later slots", async () => {
+  const paths = await createRun();
+  let submitCalls = 0;
+  const server = createServer((request, response) => {
+    if (request.method === "POST" && request.url === "/submit") {
+      submitCalls += 1;
+      if (submitCalls === 1) {
+        response.statusCode = 500;
+        response.setHeader("Content-Type", "application/json");
+        response.end(JSON.stringify({ message: "temporary failure" }));
+      } else {
+        request.socket.destroy();
+      }
+      return;
+    }
+    response.statusCode = 404;
+    response.end();
+  });
+
+  try {
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const { port } = server.address();
+    const planFile = paths.prompt_plan;
+    const outputFile = paths.jimeng_result;
+    const visionFile = paths.vision_result;
+    const plan = buildValidPlan(
+      "test product",
+      "https://images.example.test/product.jpg",
+      paths.run_id,
+    );
+    await writeFile(planFile, JSON.stringify(plan));
+    await writeFile(visionFile, JSON.stringify(visionResultForPlan(plan, "https://images.example.test/product.jpg")));
+    const env = {
+      ...process.env,
+      JIMENG_TOKEN: "test-token",
+      JIMENG_SUBMIT_URL: `http://127.0.0.1:${port}/submit`,
+      JIMENG_QUERY_URL: `http://127.0.0.1:${port}/query`,
+      JIMENG_RESULT_BASE_URL: `http://127.0.0.1:${port}/result`,
+      COMMERCE_VISION_API_KEY: "test-key",
+      COMMERCE_VISION_BASE_URL: `http://127.0.0.1:${port}/vision`,
+    };
+    const execution = await execFileAsync(process.execPath, [
+      clientFile, planFile, "--execute", "--vision-result", visionFile, "--max-cost", "1.54", "--output", outputFile,
+      "--request-attempts", "3", "--poll-seconds", "0.05", "--timeout-seconds", "1", "--unknown-cooldown-seconds", "0",
+    ], { env });
+    const summary = JSON.parse(execution.stdout);
+    assert.equal(summary.status, "partial");
+    assert.deepEqual(summary.unknown_slots, ["01", "02", "03", "04", "05", "06", "07"]);
+    assert.equal(summary.resumable, false);
+    assert.equal(summary.next_action, "deliver_partial_and_report_unknown_do_not_resubmit");
+    assert.equal(submitCalls, 8);
+    const saved = JSON.parse(await readFile(outputFile, "utf8"));
+    assert.equal(saved.results[0].status, "submission_unknown");
+    assert.equal(saved.results[0].submit_attempts, 2);
+    assert.equal(saved.results[0].uncertain_submit_attempts, 1);
+    assert.ok(saved.results.slice(1).every((item) => item.status === "submission_unknown"));
+    assert.ok(saved.results.slice(1).every((item) => item.submit_attempts === 1));
+  } finally {
+    server.close();
+    await rm(paths.run_directory, { recursive: true, force: true });
+  }
+});

+ 91 - 0
claude-code/fmode-image-set/mcp/src/server.mjs

@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+import { runImageSet } from "./tools/image-set-run.mjs";
+import { validateImageSetPlan } from "./tools/image-set-validate.mjs";
+
+const VERSION = "0.1.0";
+
+function asToolResult(result = {}) {
+  return {
+    content: [{
+      type: "text",
+      text: result.assistantMessage || JSON.stringify(result, null, 2),
+    }],
+    structuredContent: result,
+    isError: !new Set(["ok", "partial"]).has(result.status),
+  };
+}
+
+export function createServer() {
+  const server = new McpServer({
+    name: "fmode-image-set",
+    version: VERSION,
+  });
+
+  const outputSchema = {
+    status: z.string(),
+    assistantMessage: z.string(),
+    summary: z.object({}).passthrough().optional(),
+    data: z.object({}).passthrough().optional(),
+    files: z.array(z.string()).optional(),
+    nextActions: z.array(z.string()).optional(),
+    warnings: z.array(z.any()).optional(),
+    errors: z.array(z.any()).optional(),
+  };
+
+  server.registerTool(
+    "fmode_image_set_run",
+    {
+      title: "Run Fmode Image Set",
+      description: [
+        "Create, execute, resume, or inspect a product-truth-first ecommerce image-set run.",
+        "Use start for visual understanding and run allocation.",
+        "Use execute only after a valid prompt-plan exists and the user explicitly authorizes paid generation.",
+        "Use resume only for the same runId; use status for read-only inspection.",
+      ].join(" "),
+      inputSchema: {
+        action: z.enum(["start", "execute", "resume", "status"]),
+        image: z.string().optional(),
+        runId: z.string().optional(),
+        maxCost: z.union([z.number().nonnegative(), z.string()]).optional(),
+        workspaceRoot: z.string().optional(),
+        cwd: z.string().optional(),
+        claudeConfigDir: z.string().optional(),
+        sessionId: z.string().optional(),
+      },
+      outputSchema,
+    },
+    async (input) => asToolResult(await runImageSet(input)),
+  );
+
+  server.registerTool(
+    "fmode_image_set_validate",
+    {
+      title: "Validate Fmode Image Set Plan",
+      description: "Validate a prompt-plan.json before any paid image generation. Checks schema, product identity, evidence, seven-slot allocation, prompt completeness, and cross-image diversity.",
+      annotations: {
+        readOnlyHint: true,
+        destructiveHint: false,
+        idempotentHint: true,
+        openWorldHint: false,
+      },
+      inputSchema: {
+        planPath: z.string(),
+      },
+      outputSchema,
+    },
+    async (input) => asToolResult(await validateImageSetPlan(input)),
+  );
+
+  return server;
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const server = createServer();
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
+}

+ 16 - 0
claude-code/fmode-image-set/mcp/src/tools/create-run.mjs

@@ -0,0 +1,16 @@
+import { createRun } from "../core/run-paths.mjs";
+import { inspectLatestClaudeInvocation } from "../providers/claude-attachment-client.mjs";
+
+const args = process.argv.slice(2);
+const option = (name, fallback = "") => {
+  const index = args.indexOf(name);
+  return index >= 0 ? args[index + 1] : fallback;
+};
+const explicitRequestKey = option("--request-key");
+const invocation = explicitRequestKey ? null : await inspectLatestClaudeInvocation({
+  cwd: option("--cwd", process.cwd()),
+  claudeConfigDirectory: option("--claude-config-dir", process.env.CLAUDE_CONFIG_DIR),
+  sessionId: option("--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
+});
+const requestKey = explicitRequestKey || `${invocation.session_id}:${invocation.prompt_id}`;
+console.log(JSON.stringify(await createRun({ requestKey }), null, 2));

+ 141 - 0
claude-code/fmode-image-set/mcp/src/tools/image-set-run.mjs

@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+import { execFile } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { errorResult, okResult } from "../core/result-envelope.mjs";
+
+const execFileAsync = promisify(execFile);
+const toolsDirectory = dirname(fileURLToPath(import.meta.url));
+const runnerFile = resolve(toolsDirectory, "..", "features", "image-set", "image-set-runner.mjs");
+
+function parseJson(value) {
+  const text = String(value || "").trim();
+  if (!text) return null;
+  try {
+    return JSON.parse(text);
+  } catch {
+    const start = text.lastIndexOf("\n{");
+    if (start >= 0) return JSON.parse(text.slice(start + 1));
+    throw new Error("IMAGE_SET_RESULT_INVALID_JSON");
+  }
+}
+
+function collectFiles(result) {
+  const direct = Array.isArray(result?.files) ? result.files : [];
+  const fromResults = Array.isArray(result?.results)
+    ? result.results.flatMap((item) => item?.local_images || [])
+    : [];
+  const nested = Array.isArray(result?.result?.results)
+    ? result.result.results.flatMap((item) => item?.local_images || [])
+    : [];
+  return [...new Set([...direct, ...fromResults, ...nested].filter(Boolean))];
+}
+
+function buildAssistantMessage(action, result) {
+  const runId = result?.paths?.run_id || result?.result?.run_id || result?.run_id || "";
+  if (action === "start" && result?.status === "ready_for_plan") {
+    return [
+      "商品视觉理解已完成,可以编写七图计划。",
+      runId ? `运行编号:${runId}` : "",
+      result?.paths?.prompt_plan ? `计划文件:${result.paths.prompt_plan}` : "",
+      "下一步:依据视觉结果生成 prompt-plan.json,并调用 fmode_image_set_validate。",
+    ].filter(Boolean).join("\n");
+  }
+  if (result?.status === "complete") {
+    return `商品套图已完成${runId ? `,运行编号:${runId}` : ""}。`;
+  }
+  if (result?.status === "partial") {
+    return [
+      `商品套图部分完成${runId ? `,运行编号:${runId}` : ""}。`,
+      result?.summary ? `完成 ${result.summary.completed || 0} 张,失败 ${result.summary.failed || 0} 张。` : "",
+      "请根据返回的失败、未知、质量拒绝和可恢复图位继续处理。",
+    ].filter(Boolean).join("\n");
+  }
+  if (action === "status") {
+    return `已读取套图运行状态${runId ? `:${runId}` : ""}。`;
+  }
+  return result?.error?.message || "套图运行被阻断,请按结构化结果中的下一步处理。";
+}
+
+export async function runImageSet(input = {}) {
+  const action = String(input.action || "start");
+  if (!new Set(["start", "execute", "resume", "status"]).has(action)) {
+    return errorResult("needs_input", "action 必须是 start、execute、resume 或 status。");
+  }
+
+  const args = [runnerFile, action];
+  if (input.image) args.push("--image", String(input.image));
+  if (input.runId) args.push("--run-id", String(input.runId));
+  if (input.maxCost !== undefined) args.push("--max-cost", String(input.maxCost));
+  if (input.cwd) args.push("--cwd", String(input.cwd));
+  if (input.claudeConfigDir) args.push("--claude-config-dir", String(input.claudeConfigDir));
+  if (input.sessionId) args.push("--session-id", String(input.sessionId));
+
+  const env = {
+    ...process.env,
+    ...(input.workspaceRoot ? { FMODE_IMAGE_SET_PROJECT_ROOT: resolve(String(input.workspaceRoot)) } : {}),
+  };
+
+  try {
+    const { stdout } = await execFileAsync(process.execPath, args, {
+      cwd: input.workspaceRoot ? resolve(String(input.workspaceRoot)) : process.cwd(),
+      env,
+      maxBuffer: 1024 * 1024 * 20,
+    });
+    const result = parseJson(stdout);
+    const files = collectFiles(result);
+    const payload = {
+      assistantMessage: buildAssistantMessage(action, result),
+      summary: {
+        action,
+        runId: result?.paths?.run_id || result?.result?.run_id || result?.run_id || input.runId || "",
+        runtimeStatus: result?.status || "unknown",
+      },
+      data: result,
+      files,
+      nextActions: result?.next_action ? [result.next_action] : [],
+      warnings: result?.issues || [],
+    };
+    if (new Set(["blocked", "error"]).has(result?.status)) {
+      return errorResult(result.status, payload.assistantMessage, payload);
+    }
+    return okResult(payload);
+  } catch (error) {
+    let detail = null;
+    try {
+      detail = parseJson(error?.stdout || error?.stderr);
+    } catch {
+      detail = null;
+    }
+    const message = detail?.error?.message || detail?.message || error?.message || "套图运行失败";
+    return errorResult("error", message, {
+      data: detail || {},
+      nextActions: ["检查输入、运行编号和本地配置后,在同一运行中重试。"],
+    });
+  }
+}
+
+function parseArgs(argv) {
+  const input = { action: argv[0] || "start" };
+  for (let index = 1; index < argv.length; index += 1) {
+    const token = argv[index];
+    if (!token.startsWith("--")) continue;
+    const key = token.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
+    const next = argv[index + 1];
+    input[key] = next && !next.startsWith("--") ? next : true;
+    if (input[key] === next) index += 1;
+  }
+  return input;
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const input = parseArgs(process.argv.slice(2));
+  const result = await runImageSet(input);
+  if (input.assistantMessageOnly) {
+    console.log(result.assistantMessage);
+  } else {
+    console.log(JSON.stringify(result, null, 2));
+  }
+  process.exitCode = new Set(["ok", "partial"]).has(result.status) ? 0 : 1;
+}

+ 50 - 0
claude-code/fmode-image-set/mcp/src/tools/image-set-validate.mjs

@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+import { execFile } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { errorResult, okResult } from "../core/result-envelope.mjs";
+
+const execFileAsync = promisify(execFile);
+const toolsDirectory = dirname(fileURLToPath(import.meta.url));
+const validatorFile = resolve(toolsDirectory, "..", "features", "image-set", "validate-plan.mjs");
+
+export async function validateImageSetPlan(input = {}) {
+  const planPath = input.planPath || input.path;
+  if (!planPath) {
+    return errorResult("needs_input", "planPath 是必填项。");
+  }
+  try {
+    const { stdout } = await execFileAsync(process.execPath, [validatorFile, resolve(String(planPath))], {
+      maxBuffer: 1024 * 1024 * 10,
+    });
+    const validation = JSON.parse(stdout);
+    return okResult({
+      assistantMessage: `套图计划校验通过,共 ${validation.images} 张。`,
+      summary: validation,
+      data: validation,
+      files: [resolve(String(planPath))],
+      nextActions: ["用户已授权实际生图时,调用 fmode_image_set_run,action=execute。"],
+    });
+  } catch (error) {
+    let validation = { valid: false, errors: [error?.message || "计划校验失败"] };
+    try {
+      validation = JSON.parse(error?.stderr || error?.stdout || "");
+    } catch {}
+    return errorResult("plan_invalid", [
+      "套图计划校验未通过。",
+      ...((validation.errors || []).slice(0, 12).map((item) => `- ${item}`)),
+    ].join("\n"), {
+      data: validation,
+      files: [resolve(String(planPath))],
+      nextActions: ["只修改不合格的计划字段,重新校验;校验通过前不要调用生图模型。"],
+    });
+  }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  const planPath = process.argv[2];
+  const result = await validateImageSetPlan({ planPath });
+  console.log(JSON.stringify(result, null, 2));
+  process.exitCode = result.status === "ok" ? 0 : 1;
+}

+ 16 - 0
claude-code/fmode-image-set/memory-templates/image-set-profile.json

@@ -0,0 +1,16 @@
+{
+  "project": "Fmode Image Set",
+  "platform": "general_ecommerce",
+  "market": "",
+  "locale": "zh-CN",
+  "defaultImageCount": 7,
+  "businessAllocation": {
+    "hero": 1,
+    "scene": 2,
+    "selling_point": 2,
+    "decision_support": 2
+  },
+  "visualPreferences": [],
+  "blockedDirections": [],
+  "updatedAt": ""
+}

+ 1188 - 0
claude-code/fmode-image-set/package-lock.json

@@ -0,0 +1,1188 @@
+{
+  "name": "@fmode/image-set",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@fmode/image-set",
+      "version": "0.1.0",
+      "dependencies": {
+        "@modelcontextprotocol/sdk": "^1.29.0",
+        "zod": "^4.4.3"
+      },
+      "bin": {
+        "fmode-image-set": "bin/fmode-image-set.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@hono/node-server": {
+      "version": "2.0.12",
+      "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz",
+      "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "hono": "^4"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
+      "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
+      "license": "MIT",
+      "dependencies": {
+        "@hono/node-server": "^1.19.9 || ^2.0.5",
+        "ajv": "^8.17.1",
+        "ajv-formats": "^3.0.1",
+        "content-type": "^1.0.5",
+        "cors": "^2.8.5",
+        "cross-spawn": "^7.0.5",
+        "eventsource": "^3.0.2",
+        "eventsource-parser": "^3.0.0",
+        "express": "^5.2.1",
+        "express-rate-limit": "^8.2.1",
+        "hono": "^4.11.4",
+        "jose": "^6.1.3",
+        "json-schema-typed": "^8.0.2",
+        "pkce-challenge": "^5.0.0",
+        "raw-body": "^3.0.0",
+        "zod": "^3.25 || ^4.0",
+        "zod-to-json-schema": "^3.25.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@cfworker/json-schema": "^4.1.1",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "@cfworker/json-schema": {
+          "optional": true
+        },
+        "zod": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^2.0.0",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "iconv-lite": "^0.7.2",
+        "on-finished": "^2.4.1",
+        "qs": "^6.15.2",
+        "raw-body": "^3.0.2",
+        "type-is": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/eventsource": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+      "license": "MIT",
+      "dependencies": {
+        "eventsource-parser": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/eventsource-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
+      "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express-rate-limit": {
+      "version": "8.6.1",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz",
+      "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "ip-address": "^10.2.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
+    "node_modules/fast-uri": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+      "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hono": {
+      "version": "4.12.32",
+      "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz",
+      "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=16.9.0"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ip-address": {
+      "version": "10.3.1",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
+      "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jose": {
+      "version": "6.2.4",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
+      "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
+    "node_modules/json-schema-typed": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
+      "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+      "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pkce-challenge": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
+      "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.25.2",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
+      "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "zod": "^3.25.28 || ^4"
+      }
+    }
+  }
+}

+ 53 - 0
claude-code/fmode-image-set/package.json

@@ -0,0 +1,53 @@
+{
+  "name": "@fmode/image-set",
+  "version": "0.1.0",
+  "description": "Claude Code ecommerce image-set skill with product vision auditing, seven-slot planning, Jimeng generation, recovery, cost control, and delivery integrity checks.",
+  "type": "module",
+  "bin": {
+    "fmode-image-set": "bin/fmode-image-set.js"
+  },
+  "scripts": {
+    "install:check": "node install.js --check",
+    "install:smoke": "node install.js --smoke",
+    "mcp:start": "node mcp/src/server.mjs",
+    "mcp:smoke": "node scripts/smoke-mcp.mjs",
+    "smoke:package": "node scripts/smoke-package.mjs",
+    "acceptance": "node scripts/acceptance.mjs",
+    "test": "node scripts/run-tests.mjs",
+    "validate:plan": "node mcp/src/tools/image-set-validate.mjs"
+  },
+  "files": [
+    ".claude-plugin/",
+    ".mcp.json",
+    "bin/",
+    "docs/",
+    "install.js",
+    "mcp/",
+    "memory-templates/",
+    "package.json",
+    "package-lock.json",
+    "README.md",
+    "scripts/",
+    "skill-package-manifest.json",
+    "skills/"
+  ],
+  "publishConfig": {
+    "access": "public"
+  },
+  "keywords": [
+    "claude-code",
+    "ecommerce",
+    "product-image",
+    "image-set",
+    "jimeng",
+    "mcp",
+    "fmode"
+  ],
+  "engines": {
+    "node": ">=20"
+  },
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.29.0",
+    "zod": "^4.4.3"
+  }
+}

+ 15 - 0
claude-code/fmode-image-set/scripts/acceptance.mjs

@@ -0,0 +1,15 @@
+#!/usr/bin/env node
+import { execFileSync } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const run = (file, args = []) => execFileSync(process.execPath, [resolve(root, file), ...args], {
+  cwd: root,
+  stdio: "inherit",
+});
+
+run("install.js", ["--check"]);
+run("scripts/smoke-package.mjs");
+run("scripts/smoke-mcp.mjs");
+console.log(JSON.stringify({ status: "ok", acceptance: "fmode-image-set" }, null, 2));

+ 75 - 0
claude-code/fmode-image-set/scripts/package.test.mjs

@@ -0,0 +1,75 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { access, mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { smokePackage } from "./smoke-package.mjs";
+import { smokeMcp } from "./smoke-mcp.mjs";
+
+const execFileAsync = promisify(execFile);
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+test("package structure and plan validation smoke", async () => {
+  const result = await smokePackage();
+  assert.equal(result.status, "ok");
+});
+
+test("MCP exposes the image-set tools", async () => {
+  const result = await smokeMcp();
+  assert.equal(result.status, "ok");
+  assert.deepEqual(result.tools.sort(), ["fmode_image_set_run", "fmode_image_set_validate"].sort());
+  const validator = result.toolDefinitions.find((tool) => tool.name === "fmode_image_set_validate");
+  assert.deepEqual(validator.annotations, {
+    readOnlyHint: true,
+    destructiveHint: false,
+    idempotentHint: true,
+    openWorldHint: false,
+  });
+});
+
+test("skill can call its MCP tools and delegates unsupported images to the attachment bridge", async () => {
+  const skill = await readFile(join(root, "skills", "fmode-image-set", "SKILL.md"), "utf8");
+  assert.doesNotMatch(skill, /^allowed-tools:/m);
+  assert.match(skill, /\[Unsupported Image\]/);
+  assert.match(skill, /`fmode_image_set_run` 的 `action=start`/);
+  assert.match(skill, /不得先用 Glob 搜图/);
+});
+
+test("workspace install places the complete package under the discoverable skill root", async () => {
+  const workspace = await mkdtemp(join(tmpdir(), "fmode-image-set-workspace-"));
+  try {
+    await execFileAsync(
+      process.execPath,
+      [join(root, "bin", "fmode-image-set.js"), "workspace", "--skip-install"],
+      { cwd: workspace },
+    );
+
+    const target = join(workspace, ".claude", "skills", "fmode-image-set");
+    for (const relativePath of [
+      ".claude-plugin/plugin.json",
+      "bin/fmode-image-set.js",
+      "docs/capability-map.md",
+      "mcp/src/server.mjs",
+      "memory-templates/image-set-profile.json",
+      "scripts/smoke-package.mjs",
+      "skills/fmode-image-set/SKILL.md",
+      "skill-package-manifest.json",
+      "SKILL.md",
+      "references/image-set-planner.md",
+      "agents/openai.yaml",
+    ]) {
+      await access(join(target, relativePath));
+    }
+
+    const config = JSON.parse(await readFile(join(workspace, ".mcp.json"), "utf8"));
+    assert.equal(
+      resolve(config.mcpServers["fmode-image-set"].cwd),
+      resolve(target),
+    );
+  } finally {
+    await rm(workspace, { recursive: true, force: true });
+  }
+});

+ 27 - 0
claude-code/fmode-image-set/scripts/run-tests.mjs

@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+import { spawnSync } from "node:child_process";
+import { readdirSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+function collectTests(directory) {
+  const result = [];
+  for (const entry of readdirSync(directory, { withFileTypes: true })) {
+    if (entry.name === "node_modules") continue;
+    const fullPath = join(directory, entry.name);
+    if (entry.isDirectory()) result.push(...collectTests(fullPath));
+    else if (entry.isFile() && entry.name.endsWith(".test.mjs")) result.push(fullPath);
+  }
+  return result;
+}
+
+const tests = collectTests(root);
+const child = spawnSync(process.execPath, ["--test", ...tests], {
+  cwd: root,
+  stdio: "inherit",
+  encoding: "utf8",
+  maxBuffer: 1024 * 1024 * 100,
+});
+process.exit(child.status ?? 1);

+ 32 - 0
claude-code/fmode-image-set/scripts/smoke-mcp.mjs

@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+
+export async function smokeMcp() {
+  const client = new Client({ name: "fmode-image-set-smoke", version: "0.1.0" });
+  const transport = new StdioClientTransport({
+    command: process.execPath,
+    args: [resolve(root, "mcp", "src", "server.mjs")],
+    cwd: root,
+    stderr: "pipe",
+  });
+  await client.connect(transport);
+  try {
+    const result = await client.listTools();
+    const names = result.tools.map((tool) => tool.name);
+    assert.ok(names.includes("fmode_image_set_run"));
+    assert.ok(names.includes("fmode_image_set_validate"));
+    return { status: "ok", tools: names, toolDefinitions: result.tools };
+  } finally {
+    await client.close();
+  }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  console.log(JSON.stringify(await smokeMcp(), null, 2));
+}

+ 60 - 0
claude-code/fmode-image-set/scripts/smoke-package.mjs

@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { okResult } from "../mcp/src/core/result-envelope.mjs";
+import { sha256, signState, verifyState } from "../mcp/src/core/run-state-integrity.mjs";
+import { buildValidPlan } from "./test-plan-fixture.mjs";
+
+const execFileAsync = promisify(execFile);
+
+export async function smokePackage() {
+  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+  const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
+  const pluginJson = JSON.parse(await readFile(join(root, ".claude-plugin", "plugin.json"), "utf8"));
+  const manifest = JSON.parse(await readFile(join(root, "skill-package-manifest.json"), "utf8"));
+  assert.equal(packageJson.version, pluginJson.version);
+  assert.equal(pluginJson.version, manifest.version);
+  assert.equal(manifest.entrySkill, "fmode-image-set");
+
+  const envelope = okResult({ assistantMessage: "ok" });
+  assert.equal(envelope.status, "ok");
+  assert.equal(envelope.assistantMessage, "ok");
+
+  const state = { run_id: "run-20260729000000-abcdef", status: "running" };
+  const secret = "package-smoke-secret";
+  const signed = signState(state, secret);
+  assert.equal(verifyState(signed, secret), true);
+  assert.equal(sha256("image-set").length, 64);
+
+  const temp = await mkdtemp(join(tmpdir(), "fmode-image-set-"));
+  try {
+    const planPath = join(temp, "prompt-plan.json");
+    await writeFile(planPath, `${JSON.stringify(buildValidPlan(
+      "generic consumer product",
+      "https://images.example.test/product.jpg",
+      "run-20260729000000-abcdef",
+    ), null, 2)}\n`, "utf8");
+    const validator = join(root, "mcp", "src", "features", "image-set", "validate-plan.mjs");
+    const { stdout } = await execFileAsync(process.execPath, [validator, planPath]);
+    const validation = JSON.parse(stdout);
+    assert.equal(validation.valid, true);
+    assert.equal(validation.images, 7);
+  } finally {
+    await rm(temp, { recursive: true, force: true });
+  }
+
+  return {
+    status: "ok",
+    version: packageJson.version,
+    checks: ["version-sync", "result-envelope", "state-integrity", "plan-validation"],
+  };
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  console.log(JSON.stringify(await smokePackage(), null, 2));
+}

+ 142 - 0
claude-code/fmode-image-set/scripts/test-plan-fixture.mjs

@@ -0,0 +1,142 @@
+import { compileProductLock } from "../mcp/src/providers/commerce-vision-provider.mjs";
+
+function repeatToLength(seed, minimum) {
+  let result = seed;
+  while ([...result].length < minimum) result += ` ${seed}`;
+  return result;
+}
+
+const identityManifest = {
+  must_preserve: ["完整外形轮廓", "商品数量", "主体颜色", "可见结构", "标识位置"],
+  must_not_add: ["额外配件", "额外文字", "不存在的纽扣"],
+  unknown_do_not_assert: ["内部结构", "真实尺寸", "材质成分"],
+};
+identityManifest.product_lock = compileProductLock(identityManifest);
+
+const slotConfigs = [
+  ["hero", "isolated_product", "simple", "看清完整商品外观与数量", ["visible_fact:1"], "", "纯净无缝背景中只放置一个完整商品,保留充分边距、自然接触阴影与清楚轮廓。"],
+  ["selling_point", "material_demonstration", "medium", "理解第一项可见材质价值", ["visible-sp-1"], "visible-sp-1", "以单一方向的掠射光划过商品表面,并用明暗渐变和局部微观纹理展示第一项可见材质特征;画面仍保留足够完整外形作为识别锚点,不加入说明文字或虚构性能。"],
+  ["scene", "environmental_scene", "complex", "理解商品与完整生活空间的关系", ["suggested_scene:1"], "", "建立具有前景家具、中景商品与远景建筑层次的宽幅生活空间。商品自然位于真实承托面上,附近道具只用于交代尺度与情境,左右留出呼吸空间;环境细节、材质反射与光影方向互相呼应,不宣称未经证实的适用性。"],
+  ["selling_point", "feature_demonstration", "medium", "理解第二项可见结构价值", ["visible-sp-2"], "visible-sp-2", "用侧向中近景展示与第二项卖点相关的可见结构,并让完整商品处于同一连续空间的后景作为关系参照;通过景深和连续空间层次区分重点,禁止虚构内部构造、功能或性能。"],
+  ["decision_support", "multi_angle", "medium", "从多个真实可见角度完成外观判断", ["visible_fact:2"], "", "在唯一一张多格构图中,以等比例、等光色的三个外观视角建立横向阅读顺序;所有视角都只展示参考图支持的外部表面,间距清楚,不生成未知背面、内部、附件或说明文字。"],
+  ["scene", "human_interaction", "complex", "理解人物动作与商品之间的关系", ["suggested_audience:1"], "", "人物从画面侧边进入,以明确、自然且符合商品可见结构的手部动作与商品发生联系。前景动作、中景商品和背景环境形成三层空间,服装、道具与光线克制统一;商品仍是视觉焦点,不遮挡身份特征,不添加未证实用途。"],
+  ["decision_support", "usage_sequence", "medium", "理解商品在单一使用时刻中的状态关系", ["visible_fact:3"], "", "在一个连续、真实的桌面或生活空间中呈现商品与必要使用动作的前后关系,以视线方向、手部位置和物体间距传达顺序,保持单一连续画面,不加入步骤文字、测量值、营销结论或未知附件。"],
+].map(([category, render, complexity, decision, evidence, sellingPointId, scene]) => ({
+  category, render, complexity, decision, evidence, sellingPointId, scene,
+}));
+
+function buildModules(index, config) {
+  const sceneMinimum = config.complexity === "complex" ? 175 : config.complexity === "medium" ? 75 : 25;
+  return {
+    product_lock: identityManifest.product_lock,
+    image_job: `第${index + 1}张图只承担一个决策任务:${config.decision}。`,
+    scene: repeatToLength(config.scene, sceneMinimum),
+    composition: `第${index + 1}张使用独立的裁切、占比、边距、空间深度与阅读顺序,不能复用其他槽位的机位关系。`,
+    lighting_material: `第${index + 1}张使用独立光线方向、光源面积、软硬度、色温、阴影和真实材质响应。`,
+    camera_style: config.complexity === "complex"
+      ? `为第${index + 1}张指定独立镜头焦段、焦平面和景深过渡。`
+      : `第${index + 1}张采用独立的商业摄影机位与成像方式。`,
+    text_layout: "",
+    negative_constraints: `禁止商品漂移、无关物体、重复商品、虚构卖点、水印和额外品牌;禁止断言内部结构、真实尺寸或材质成分。`,
+  };
+}
+
+export function compilePrompt(modules) {
+  return Object.values(modules).filter(Boolean).join(" ");
+}
+
+export function buildValidPlan(
+  category = "generic consumer product",
+  reference = "https://images.example.test/product.jpg",
+  runId = `cross-category-${category.replace(/\s+/g, "-")}`,
+) {
+  return {
+    schema_version: "0.6",
+    run_id: runId,
+    status: "ready",
+    user_output_mode: "images_only",
+    request: {
+      instruction: "这是我的产品,帮我生成一套通用电商七图。",
+      reference_images: [reference],
+    },
+    context: {
+      platform: "general_ecommerce",
+      market: "unspecified",
+      voc: { status: "unavailable", findings: [] },
+    },
+    product_understanding: {
+      category,
+      product_name: { value: category, basis: "verified visible evidence", confidence: "medium" },
+      visible_facts: ["完整外形可见", "正面与侧面轮廓可见", "可见操作结构", "主色和表面材质观感可见"],
+      verified_visible_facts: ["完整外形可见", "正面与侧面轮廓可见", "可见操作结构", "主色和表面材质观感可见"],
+      inferred_attributes: [],
+      unknown_features: ["内部结构", "真实尺寸", "材质成分"],
+      identity_manifest: identityManifest,
+      provided_facts: [],
+      candidate_selling_points: [
+        { id: "visible-sp-1", value: "可见材质与主色形成清晰的视觉识别", basis_refs: ["visible_fact:4"], confidence: "high" },
+        { id: "visible-sp-2", value: "可见结构关系让商品外观层次容易理解", basis_refs: ["visible_fact:2", "visible_fact:3"], confidence: "high" },
+      ],
+      visible_specs: { visible_quantity: "1", exterior_state: "complete" },
+      creative_positioning: ["克制、清晰的商业视觉"],
+      suggested_audiences: ["重视清晰商品信息的消费者"],
+      suggested_scenes: ["整洁、可信的日常使用环境"],
+      unsupported_claims: ["经过认证", "超长耐用"],
+      unknowns: ["内部结构", "真实尺寸", "材质成分"],
+    },
+    global_consistency_lock: {
+      product_invariants: identityManifest.must_preserve,
+      visual_system: ["统一商品身份与真实材质响应"],
+      prohibited_changes: identityManifest.must_not_add,
+    },
+    set_blueprint: {
+      business_allocation: { hero: 1, scene: 2, selling_point: 2, decision_support: 2 },
+      visual_thesis: "用七个不可互换的购买任务让商品可识别、可理解、可代入并可判断。",
+      narrative_arc: ["识别", "理解价值", "代入", "决策"],
+      shared_visual_dna: ["商品身份", "核心色彩", "真实材质"],
+      intentional_variations: ["业务任务", "空间", "人物", "景别", "信息关系"],
+    },
+    images: slotConfigs.map((config, index) => {
+      const modules = buildModules(index, config);
+      return {
+        id: String(index + 1).padStart(2, "0"),
+        role: config.category,
+        business_category: config.category,
+        decision_subject: config.decision,
+        evidence_refs: config.evidence,
+        selling_point_id: config.sellingPointId,
+        purpose: config.decision,
+        voc_refs: [],
+        set_design: {
+          render_family: config.render,
+          scene_complexity: config.complexity,
+          unique_visual_hook: `槽位${index + 1}使用${config.render}形成不可互换的视觉任务。`,
+          contrast_with_previous: index === 0 ? "开场" : `槽位${index + 1}改变景别、空间、动作和信息关系。`,
+        },
+        shot_spec: {
+          narrative_job: `job-${index + 1}`,
+          composition_family: ["centered hero", "diagonal material field", "wide room depth", "single-space feature relation", "multi-angle grid", "human action frame", "continuous usage moment"][index],
+          shot_scale: ["full product", "medium product", "wide environment", "detail and whole", "multi-view", "human medium-wide", "context close-medium"][index],
+          camera_angle: ["eye level", "low oblique", "high wide", "side close", "three-quarter grid", "shoulder height", "top-oblique context"][index],
+          camera_axis: ["front", "low", "three_quarter_left", "side", "three_quarter_right", "front", "top"][index],
+          framing_mode: ["full_product", "detail", "product_in_context", "full_product", "multi_panel", "human_action", "product_in_context"][index],
+          product_pose: ["upright", "detail_crop", "upright", "arranged_sequence", "multi_view", "held", "laid_flat"][index],
+          product_placement: `distinct placement ${index + 1}`,
+          product_occupancy_pct: [70, 45, 25, 50, 60, 20, 55][index],
+          depth_structure: `distinct depth structure ${index + 1}`,
+          background_family: ["pure seamless", "material field", "lived-in room", "feature stage", "neutral grid", "human environment", "continuous context"][index],
+          lighting_family: ["even studio", "directional material", "window ambient", "edge definition", "flat comparative", "motivated environmental", "clear contextual"][index],
+          interaction_mode: ["single accurate product", "material relation", "environment placement", "feature relation", "multiple exterior views", "human action", "single moment relation"][index],
+          information_density: ["low", "medium", "medium", "medium", "high", "medium", "medium"][index],
+        },
+        prompt_modules: modules,
+        generation_prompt: compilePrompt(modules),
+        negative_prompt: "deformation, watermark",
+        overlay_copy: [],
+        reference_images: [reference],
+        model_parameters: { adapter: "jimeng_img_v4", width: 2048, height: 2048, scale: 0.6, force_single: true },
+        acceptance_checks: ["product identity preserved", "decision task is visible"],
+      };
+    }),
+  };
+}

+ 35 - 0
claude-code/fmode-image-set/skill-package-manifest.json

@@ -0,0 +1,35 @@
+{
+  "name": "fmode-image-set",
+  "version": "0.1.0",
+  "description": "Claude Code native ecommerce image-set package with evidence-bounded planning and traceable generation.",
+  "plugin": "fmode-image-set",
+  "skills": [
+    "fmode-image-set"
+  ],
+  "mcpTools": [
+    "fmode_image_set_run",
+    "fmode_image_set_validate"
+  ],
+  "entrySkill": "fmode-image-set",
+  "capabilityMap": "docs/capability-map.md",
+  "customerQuickstart": "docs/customer-quickstart.md",
+  "liveAcceptanceChecklist": "docs/live-manual-acceptance-checklist.md",
+  "deliverableContract": "docs/deliverables/image-set-result.md",
+  "primaryUseCases": [
+    "Understand a user-provided product image without inventing hidden product facts",
+    "Plan one coherent seven-image ecommerce set with distinct business roles",
+    "Compile and validate per-slot generation prompts",
+    "Generate through Jimeng with cost caps, resumable state, and delivery integrity checks",
+    "Reject generated images that drift from the audited product identity"
+  ],
+  "recommendedUserPrompts": [
+    "给这款商品做一套高级、适合电商详情页的七图。",
+    "先帮我规划这套图,不要实际生图。",
+    "继续恢复刚才那套图,只处理允许恢复的图位。"
+  ],
+  "installCommand": "node install.js",
+  "workspaceInstallCommand": "fmode-image-set workspace --smoke",
+  "workspaceInstallTarget": ".claude/skills/fmode-image-set",
+  "mcpSmokeCommand": "npm run mcp:smoke",
+  "packageSmokeCommand": "npm run smoke:package"
+}

+ 105 - 0
claude-code/fmode-image-set/skills/fmode-image-set/SKILL.md

@@ -0,0 +1,105 @@
+---
+name: fmode-image-set
+description: Understand one product image and a short business request, plan a coherent seven-image ecommerce set, validate product truth and non-repetition, and optionally generate the approved set through Jimeng. Use when the user asks for 商品套图、电商七图、白底主图、卖点图、场景图、购买决策图、整套商品图 or wants one product image turned into a consistent image set.
+---
+
+# Fmode Image Set
+
+你是面向电商运营、品牌方和设计人员的商品套图工作台。用户只需要提供一张商品图和一句业务需求;不要要求用户编写生图 Prompt、镜头、灯光、七图角色或负面词。
+
+## 必须执行
+
+- 用户要求商品套图、电商七图、主图、卖点图或场景图时,优先运行 `fmode_image_set_run`,不要只凭经验给七条提示词。
+- 即使当前对话模型把附件显示为 `[Unsupported Image]`,也必须先运行 `fmode_image_set_run` 的 `action=start`,让 MCP 从当前 Claude 会话提取原始图片;不得先用 Glob 搜图,也不得要求用户重复提供路径。
+- 首次处理使用 `action=start`,由工具创建唯一运行目录并完成商品视觉理解。
+- 根据视觉结果和用户需求编写 `prompt-plan.json`,然后运行 `fmode_image_set_validate`。
+- 只有用户明确要求实际生图时,才运行 `action=execute`;仅规划、分析、评审或调试请求不得消费生图费用。
+- 中断后只能对同一个 `runId` 使用 `action=resume`,不得创建新运行绕过状态、费用或失败记录。
+- 第一轮没有指定平台时使用 `general_ecommerce`;没有 VOC 时记录 `voc.status=unavailable` 并继续,不得伪造 VOC。
+
+## 输入与证据
+
+- 商品图片可证明外形、颜色、可见结构、Logo 和可见文字,不能证明功效、成分、销量、认证或不可见结构。
+- 信息优先级:用户明确事实 > 官方资料 > 图片可见事实 > VOC/竞品/趋势 > 默认设计规则。
+- VOC 用来决定用户关心什么,不自动证明商品具备某项能力。
+- 竞品素材只能用于抽象构图和信息顺序,不能作为我方商品参考图或复制品牌资产。
+- 无法确认的关键事实必须进入未知项或禁止断言项。
+
+需要理解证据分层时读取 `references/product-understanding.md`。
+
+## 套图规划
+
+当前版本固定生成七张,业务配额为:
+
+```text
+1 张 hero
+2 张 scene
+2 张 selling_point
+2 张 decision_support
+```
+
+每张只承担一个主要决策任务,整套遵循:
+
+```text
+看清商品 → 理解价值 → 代入场景 → 补足判断
+```
+
+两张卖点图必须引用不同、可追溯的候选卖点;两张场景图必须使用不同空间或人物关系;两张购买决策图必须解决不同购买疑问。不要用“同一商品换背景”伪装成七个不同任务。
+
+规划时读取:
+
+- `references/image-set-planner.md`
+- `references/prompt-compiler.md`
+- `references/plan-schema.md`
+
+## 商品一致性
+
+- `identity_manifest`、`verified_visible_facts` 和每张图的 `product_lock` 必须来自工具返回的视觉审计结果,不得改写。
+- 未知属性不得写进正向 Prompt。
+- 生成后出现结构漂移、新增配件、Logo/包装文字变化或商品数量错误时,必须拒绝该图。
+- 不得从其他运行复制图片补齐当前运行。
+
+## 生成与费用
+
+- 实际执行前必须先通过计划校验。
+- 默认费用上限为 `1.54` 元,对应七张各生成一次;需要提高预算时必须得到用户明确授权。
+- 正常执行顺序是:连接预检 → 单张提交 → 等待完成 → 下载 → 商品身份复核 → 下一张。
+- `submission_unknown` 不得自动重提,避免重复扣费。
+- `quality_rejected` 不自动补图。
+- 部分完成时如实返回成功、失败、未知、拒绝和可恢复图位。
+
+需要调用即梦时再读取 `references/jimeng-v4.md`。
+
+## 用户输出
+
+成功时默认只返回:
+
+- 01–07 七张本地图片;
+- 每张图片的绝对路径;
+- 成品文件夹绝对路径;
+- 一句简短完成状态。
+
+部分完成或阻断时返回:
+
+- 当前状态;
+- 已成功图位;
+- 失败、未知或质量拒绝图位;
+- 已发生费用范围;
+- 是否允许在同一运行恢复;
+- 用户下一步需要做什么。
+
+不要默认展示内部 Prompt、商品理解 JSON、模型参数、状态签名或调试日志。只有用户明确要求诊断信息时再展开。
+
+## 典型用户话术
+
+```text
+给这款商品做一套高级、适合电商详情页的七图。
+```
+
+```text
+先帮我规划这套图,不要实际生图。
+```
+
+```text
+继续恢复刚才那套图,只处理允许恢复的图位。
+```

+ 4 - 0
claude-code/fmode-image-set/skills/fmode-image-set/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "Fmode Image Set"
+  short_description: "Plan and generate consistent ecommerce image sets."
+  default_prompt: "Use $fmode-image-set to turn this product image into a coherent ecommerce image set."

+ 130 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/image-set-planner.md

@@ -0,0 +1,130 @@
+# 通用七图规划器
+
+七张是 v0 的固定交付数量,使用智能匹配,不要求用户手动配置。业务结构固定为:
+
+- `hero` × 1:标准商品白底图;
+- `scene` × 2:环境场景与人物/使用关系;
+- `selling_point` × 2:两个不同且有证据的商品价值;
+- `decision_support` × 2:帮助完成规格、结构、步骤、包装、尺度、多角度或比较判断。
+
+这四类回答“图片讲什么”。`render_family`只回答“如何表达”,不能代替业务任务。
+
+## 先做商品理解
+
+读取 [product-understanding.md](product-understanding.md)。先建立商品名称、可见事实、候选卖点、可见规格、创意定位、建议人群/场景和禁止声明,再规划图片。
+
+卖点图只能引用:
+
+1. 用户明确事实;
+2. 图片可见事实形成的 `visible_selling_points`;
+3. 有具体证据的VOC结论。
+
+视觉氛围、人群和场景建议可以控制创意,但不能成为功能、性能或适用性声明。
+
+## 业务结构
+
+### 1. 白底图
+
+固定一张,通常为01。只负责完整、准确、移动端清晰地展示商品。不得承担第二个氛围广告任务。
+
+### 2. 两张场景图
+
+两张必须解决不同问题:
+
+- 一张强调商品与完整环境的关系;
+- 另一张强调人物、动作、步骤或不同使用时刻。
+
+无可靠人群信息时,人物只是创意角色,不在画面文字中声称商品“适合”该人群。两张不得只是更换房间背景。
+
+### 3. 两张卖点图
+
+分别引用不同 `selling_point_id`。每张必须明确:
+
+- 具体可见特征;
+- 该特征带来的可见价值;
+- 采用什么画面关系证明,而不是只把商品重新拍一遍。
+
+允许的渲染家族:
+
+- `material_demonstration`:用真实物理光线、纹理或形态关系解释可见材质;
+- `feature_demonstration`:通过动作、结构关系或局部与整体的并置解释特征;
+- `annotated_detail`:只有确定性后期标注可用时才选择;当前Demo默认不用。
+
+两张卖点图不得使用同一渲染家族,不得都表现成大比例单品棚拍。
+
+### 4. 两张购买决策图
+
+根据现有证据智能选择:
+
+- `multi_angle`:多角度外观关系;
+- `usage_sequence`:有依据的使用步骤;
+- `package_contents`:参考图或资料中确实存在的包装/配件;
+- `scale_context`:有可靠尺寸或真实参照关系;
+- `comparison_sequence`:有可靠对比证据;
+- `structured_information`:只有确定性文字合成器可用时使用。
+
+两张必须回答不同购买问题。没有可靠尺寸、包装、步骤或比较信息时,优先使用多角度外观和基于可见结构的顺序展示,不得编造数据,也不得用“商品加留白”冒充信息图。
+
+## 渲染家族
+
+标准值:
+
+- `isolated_product`
+- `environmental_scene`
+- `human_interaction`
+- `usage_demonstration`
+- `material_demonstration`
+- `feature_demonstration`
+- `annotated_detail`
+- `multi_angle`
+- `usage_sequence`
+- `package_contents`
+- `scale_context`
+- `comparison_sequence`
+- `structured_information`
+
+业务分类与渲染家族必须匹配。`brand_editorial`不属于通用默认七图的独立业务任务;需要品牌氛围时,将其作为场景图的视觉方向,而不是额外增加一张静态商品照。
+
+## 证据与去重
+
+每张填写:
+
+- `business_category`
+- `decision_subject`
+- `evidence_refs`
+- 卖点图的 `selling_point_id`
+- `render_family`
+- `unique_visual_hook`
+- `contrast_with_previous`
+
+要求:
+
+- 七张 `decision_subject` 不重复;
+- 两张卖点图引用两个不同卖点ID;
+- 两张场景图使用不同渲染家族;
+- 两张购买决策图使用不同渲染家族;
+- `isolated_product`只能用于白底图;
+- 静态单品类画面(白底、材质单品、纯局部)全套最多两张;
+- 不能靠改写字段名称绕过去重。如果删除背景名词后仍是“大商品 + 台面 + 单一光源”,视为同一画面逻辑;
+- 不允许把卖点图、细节图、信息图和品牌图全部实现成商品单独陈列。
+
+## 镜头节奏
+
+先确定业务任务和证据,再填写 `shot_spec`。除自然语言机位说明外,必须填写规范化的 `camera_axis`、`framing_mode` 与 `product_pose`。至少覆盖五种构图、四种景别、四种机位轴、四种画面类型、五种背景结构和四种布光;最大与最小商品占比相差至少35个百分点。
+
+除白底图外,任何两图不得复用同一个“机位轴 + 画面类型 + 商品姿态”组合。若两图机位轴和画面类型相同,商品占比还必须至少相差15个百分点。尤其不能让场景图与购买决策图仅因业务名称不同而使用相同拍摄角度。
+
+统一视觉只锁定商品身份、核心色彩、材质物理表现和后期质感。空间深度、主体占比、动静关系、信息密度和画面关系必须变化。
+
+推荐顺序是:
+
+`白底识别 → 卖点解释 → 环境代入 → 第二卖点 → 决策辅助 → 人物/动作 → 第二决策辅助`
+
+这是节奏建议,不是品类模板。允许根据商品与证据调整顺序,但01必须是白底图,且业务配额不能改变。
+
+## 缺少上下文
+
+- VOC为空:继续执行,`voc_refs`为空,不写消费者普遍结论。
+- 平台/市场为空:使用 `general_ecommerce`,不声称符合某平台硬规则。
+- 商品资料为空:使用可见卖点和可见规格,不编造功能卖点。
+- 目标人群为空:使用视觉服务给出的创意假设,不写产品适用性结论。

+ 36 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/interaction.md

@@ -0,0 +1,36 @@
+# 极简交互规则
+
+## 默认输入
+
+用户通常只提供:
+
+- 一张或多张商品参考图;
+- 一句自然语言,例如“这是我的产品,帮我生成一套图”。
+
+以下内容均为可选机器上下文,不要求用户填写:商品资料、商品品类、目标平台、市场、VOC、用户人群、品牌风格和具体场景想法。
+
+## 自动补全
+
+- 从商品图识别可见商品信息和品类。
+- 从可见事实自动生成带证据的候选卖点、可见规格、创意定位、建议人群和建议场景;不要求用户填写。
+- 没有平台或市场时使用 `general_ecommerce`。
+- 没有 VOC 时使用 `unavailable`,依据商品与通用购买决策规划。
+- 没有风格时,从商品的颜色、材质、形态和价格感知选择克制的商业视觉方向。
+- 没有具体图位想法时,自动完成 `1张白底 + 2张场景 + 2张卖点 + 2张购买决策` 的七图设计。
+
+## 何时提问
+
+只在以下情况提一个最短问题:
+
+1. 没有可访问的商品图;
+2. 图片中存在多个商品,无法判断哪个是目标商品;
+3. 多张参考图明显属于不同 SKU;
+4. 用户要求的创意会改变商品本身,且无法判断是否允许。
+
+不要为了确认平台、市场、品类、风格、镜头、道具、图位顺序或 Prompt 而提问。
+
+## 输出纪律
+
+普通模式只输出七张结果和完成状态。内部分析、计划和 Prompt 写入运行记录,不作为用户操作步骤。
+
+只通过 `fmode_image_set_run` 启动、执行和恢复。同一用户请求重复启动时,程序必须返回原 `run_id`。视觉适配器或计划执行器返回 `blocked` 时,原样返回其 `error`、`retryable` 和 `next_action` 并停止。执行结果为 `partial` 且存在 `resumable_slots` 时,只恢复同一运行;不存在可恢复图位时直接交付部分结果。禁止创建新运行、改写结果、扫描旧运行、跨目录复制图片或让用户选择绕过方式。相同 `vision_result` 的重复命令由程序直接返回已有结果,不产生新的外部调用。

+ 62 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/jimeng-v4.md

@@ -0,0 +1,62 @@
+# 即梦4.0执行适配器
+
+本 Skill 只使用即梦图片生成4.0接口,不使用即梦4.6样图或4.6流程。
+
+## 接口
+
+- 提交:`POST https://server.fmode.cn/api/volcengine/jimeng/getImgV4`
+- 轮询:`POST https://server.fmode.cn/api/volcengine/jimeng/getDataByTask02`
+- `routerName`: `getImgV4`
+- 结果:`GET https://server.fmode.cn/parse/classes/ImagineWork/{workId}`
+- 结果头:`X-Parse-Application-Id: ncloudmaster`
+- 参考成本:0.22元/张,七张一次成功约1.54元。
+
+## 输入限制
+
+- `prompt` 必填;
+- `image_urls` 支持0–10张公网URL;商品套图必须至少有一个真实商品参考URL;
+- 默认 `sizeDate={width:2048,height:2048}`;
+- 默认 `scale=0.6`、`force_single=true`;
+- 单条Prompt不超过800字符;
+- 本地文件路径不能直接传给远端接口;适配器会先自动上传七牛,再把所得公网URL传给即梦。
+
+## 凭证
+
+优先读取环境变量 `JIMENG_TOKEN`,也可读取用户目录下 `.openclaw/voc-credentials.json`。绝不把Token写入计划、日志、运行结果或HTML。
+
+不要复制接口文档中的 `currentToken` 作为Skill默认值。
+
+七牛配置优先读取环境变量 `QINIU_ACCESS_KEY`、`QINIU_SECRET_KEY`、`QINIU_BUCKET`、`QINIU_CDN_DOMAIN`、`QINIU_UPLOAD_URL`,也可读取用户目录下 `.openclaw/voc-credentials.json` 的 `qiniu` 对象。密钥只存在用户级配置中,不得写入 Skill、计划、示例、日志或结果文件。
+
+## 执行
+
+执行前必须先运行 `product-vision-client.mjs`,并使用其 `status=complete` 的结果生成 Prompt Plan。该步骤由 Skill 内部自动完成,当前对话模型即使是纯文本 DeepSeek 也不需要直接读取图片。
+
+先调用统一 MCP 入口 `fmode_image_set_run`,并使用 `action=start`。它按 Claude 当前
+`session_id + prompt_id`为本次用户请求分配唯一运行,并把所有产物固定在:
+
+`生成结果/电商七图/<run_id>/`
+
+`run_id` 的日期和时间使用 Asia/Shanghai。目录内只使用 `商品附件/`、`成品图/`、`vision-result.json`、`prompt-plan.json`、`reference-assets.json` 和 `jimeng-result.json`。中文只用于本地交付目录和图片文件名;API字段、远程URL、run_id和状态字段继续使用英文。执行器拒绝 Skill 源码目录、临时目录或其他自定义位置。
+
+底层 `jimeng-v4-client.mjs` 的 dry-run 与素材准备模式仅供执行器测试使用,Claude不得直接调用。统一入口会在实际执行前完成计划校验、素材去重、七牛上传和费用检查;上传失败时不会调用即梦。
+
+只有用户明确要求实际生图、即梦凭证存在且费用上限足够时才执行:
+
+```powershell
+node "<插件目录>/mcp/src/tools/image-set-run.mjs" execute --run-id <run_id> --max-cost 1.54
+```
+
+正常路径严格串行:先用不计费的结果GET请求预热同域名连接,再提交当前图位并立即持久化 `workId`,等待该图位完成、下载本地并通过商品身份复核后,才提交下一图位,禁止先连续提交七张再统一查询。预热失败时不得POST,该图位保留为可恢复的 `transport_unavailable`。单个图位在程序内重试和等待耗尽后会被隔离,执行器继续处理后续图位。
+
+明确收到响应的 HTTP 429/5xx 提交错误由执行器有限重试;没有收到任何响应的提交错误记为 `submission_unknown`,因为现有服务没有客户端幂等ID和按客户端ID查询能力,后台可能已经受理,禁止再次POST。执行器安全冷却后跳过该图位并继续后续图位。查询网络错误会在该图位的总等待时间内持续退避重试;结果已生成但下载失败时保留图片URL;超时或失败不会清空 `workId`。
+
+生成图会再次与原始参考图进行视觉身份复核。新增或删除结构、明显改变轮廓/颜色/材质观感、标签变化或多格图内商品互不一致时记为 `quality_rejected`:保留本地文件供调试,但不作为合格结果交付,也不自动补图。
+
+未完成七张时返回 `partial`,同时给出 `failed_slots`、`unknown_slots`、`rejected_slots`、`resumable_slots`、费用区间和通过复核的本地图片。再次使用完全相同的计划、视觉结果与状态文件可恢复确定失败或已有 `workId` 的图位;`submission_unknown` 在任何恢复中都不会重提,`quality_rejected` 不会自动重生。
+
+`jimeng-result.json` 带 HMAC 完整性签名,并记录计划与视觉结果哈希。手工编辑状态、替换计划或替换视觉结果会被执行器拒绝,避免重复扣费和不可追溯重提。
+
+成功结果会自动下载到本轮固定的 `成品图/` 目录,并以 `01-白底主图.png`、`02-卖点图.png` 等名称写入 `local_images`。任务中断后使用 `fmode_image_set_run` 的 `action=resume`;它只恢复状态账本允许的图位。
+
+执行器同时返回使用真实槽位号标记的 `image_markdown_links` 和可直接运行的 `open_folder_command`。Claude必须原样使用这些字段交付结果,不得按成功顺序重新编号,也不得自行拼接反斜杠Markdown文件夹链接。

+ 115 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/plan-schema.md

@@ -0,0 +1,115 @@
+# 内部 Prompt Plan 结构
+
+计划仅供 Skill、调试和下游模型调用,不是普通用户界面。
+
+```json
+{
+  "schema_version": "0.6",
+  "run_id": "string",
+  "status": "ready|needs_input",
+  "user_output_mode": "images_only",
+  "request": {
+    "instruction": "string",
+    "reference_images": ["path-or-url"]
+  },
+  "context": {
+    "platform": "general_ecommerce|string",
+    "market": "unspecified|string",
+    "voc": {"status": "unavailable|available", "findings": []}
+  },
+  "product_understanding": {
+    "category": "string",
+    "product_name": {"value":"string","basis":"string","confidence":"high|medium|low|unknown"},
+    "visible_facts": [],
+    "verified_visible_facts": [],
+    "inferred_attributes": [],
+    "unknown_features": [],
+    "identity_manifest": {
+      "must_preserve": ["至少3项商品身份特征"],
+      "must_not_add": ["参考图不存在且不得增加的结构"],
+      "unknown_do_not_assert": ["不能确定且不得写入正向Prompt的属性"],
+      "product_lock": "由程序确定性编译的20—160字符商品身份锁"
+    },
+    "provided_facts": [],
+    "candidate_selling_points": [
+      {"id":"visible-sp-1","value":"string","basis_refs":["visible_fact:1"],"confidence":"high|medium|low|unknown"}
+    ],
+    "visible_specs": {},
+    "creative_positioning": [],
+    "suggested_audiences": [],
+    "suggested_scenes": [],
+    "unsupported_claims": [],
+    "unknowns": []
+  },
+  "global_consistency_lock": {
+    "product_invariants": [],
+    "visual_system": [],
+    "prohibited_changes": []
+  },
+  "set_blueprint": {
+    "business_allocation": {"hero":1,"scene":2,"selling_point":2,"decision_support":2},
+    "visual_thesis": "整套图共同表达的视觉命题",
+    "narrative_arc": ["识别", "理解价值", "代入", "决策"],
+    "shared_visual_dna": ["只描述跨图必须统一的内容"],
+    "intentional_variations": ["空间", "人物", "信息任务", "景别", "画面关系"]
+  },
+  "images": [
+    {
+      "id": "01",
+      "role": "hero",
+      "business_category": "hero|scene|selling_point|decision_support",
+      "decision_subject": "本图唯一购买问题",
+      "evidence_refs": ["visible_fact:1"],
+      "selling_point_id": "",
+      "purpose": "string",
+      "voc_refs": [],
+      "set_design": {
+        "render_family": "isolated_product|environmental_scene|human_interaction|usage_demonstration|material_demonstration|feature_demonstration|annotated_detail|multi_angle|usage_sequence|package_contents|scale_context|comparison_sequence|structured_information",
+        "scene_complexity": "simple|medium|complex",
+        "unique_visual_hook": "本图不可被其他图位替代的可见设计",
+        "contrast_with_previous": "与上一张的变化;01写opening"
+      },
+      "shot_spec": {
+        "narrative_job": "本图唯一销售任务",
+        "composition_family": "构图家族",
+        "shot_scale": "景别",
+        "camera_angle": "机位",
+        "camera_axis": "front|three_quarter_left|three_quarter_right|side|top|low|macro",
+        "framing_mode": "full_product|product_in_context|detail|multi_panel|human_action",
+        "product_pose": "upright|laid_flat|held|in_use|multi_view|detail_crop|arranged_sequence",
+        "product_placement": "商品位置",
+        "product_occupancy_pct": 60,
+        "depth_structure": "前中后景关系",
+        "background_family": "背景结构",
+        "lighting_family": "布光家族",
+        "interaction_mode": "商品与环境/人物/信息的关系",
+        "information_density": "low|medium|high"
+      },
+      "prompt_modules": {
+        "product_lock": "必须逐字等于product_understanding.identity_manifest.product_lock",
+        "image_job": "本图唯一任务",
+        "scene": "空间、人物、道具及相互关系",
+        "composition": "机位、景别、位置、占比、裁切和留白",
+        "lighting_material": "光源及材料响应",
+        "camera_style": "镜头、景深和真实度",
+        "text_layout": "",
+        "negative_constraints": "本图专属禁止项"
+      },
+      "generation_prompt": "string",
+      "negative_prompt": "string",
+      "overlay_copy": [],
+      "reference_images": [],
+      "model_parameters": {
+        "adapter": "jimeng_img_v4",
+        "width": 2048,
+        "height": 2048,
+        "scale": 0.6,
+        "force_single": true
+      },
+      "acceptance_checks": []
+    }
+  ]
+}
+```
+
+`status=ready`时必须恰好七项,01为白底图,业务分类严格为1+2+2+2。卖点图必须引用不同的 `candidate_selling_points.id`。整套最多一张 `detail`、一张 `multi_panel`;非多格槽位不得使用分格语义。计划中的 `identity_manifest` 与 `verified_visible_facts` 必须和视觉审计结果逐字一致。当前执行链没有确定性文字合成器,因此 `overlay_copy`保持空数组,不得选择依赖长文字的假信息图。`needs_input`只用于无法识别目标商品。

+ 42 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/product-understanding.md

@@ -0,0 +1,42 @@
+# 通用商品理解
+
+商品理解必须先于七图规划,并且适用于任意品类。视觉适配器固定执行初次提取与同图事实审计两步,不把审美联想升级为商品事实。卖点ID和事实引用属于执行器内部字段,始终由程序重新编号、过滤和补齐;证据引用按卖点与可见事实的文本相似度匹配,不按数组位置盲目绑定。商品身份特征执行近似语义去重,避免同一瓶身、泵头或包装结构以不同措辞重复进入商品锁。外部视觉模型只负责输出商品语义,不得因为模型少写卖点、重复ID或引用格式波动而阻断整条流程。只有无法取得任何商品可见事实时,才判定视觉理解不可用。
+
+## 输出层级
+
+1. `product_name`:优先使用图片中可辨认的品牌、系列、型号或品类;无法确认时使用不带品牌的品类名称。
+2. `visible_facts`:外形、数量、颜色、材质观感、结构、包装、标签位置和可见文字。
+3. `visible_selling_points`:把可见事实转写成2–4条消费者能理解的视觉购买理由。最终结果中的每条都由程序附加一个或多个 `visible_fact:N`;视觉模型遗漏内部编号时由程序修复,只描述图片能够证明的价值。
+4. `visible_specs`:仅记录图片可见的规格属性;容量、尺寸、成分、性能、兼容性等无法确认时不得补写。
+5. `creative_positioning`:由颜色、形态和材质得到的视觉方向,不是商品事实。
+6. `suggested_audiences`、`suggested_scenes`:用于套图创意的假设,不得写成“适合”“专为”等产品结论。
+7. `unsupported_claims`:图片无法证明、禁止进入Prompt和画面文案的功能、体验或合规结论。
+8. `verified_visible_facts`:二次审计后确认的可见事实;计划必须逐字复制。
+9. `inferred_attributes`、`unknown_features`:可推测项与不可确认项;二者都不能冒充商品事实。
+10. `identity_manifest`:包含 `must_preserve`、`must_not_add`、`unknown_do_not_assert` 和程序生成的 `product_lock`,用于锁定跨图商品身份。
+
+## 可见卖点写法
+
+可见卖点必须同时包含“特征”和“可见价值”,例如:
+
+- “折叠结构收起后占用空间较小”只有在折叠状态或结构确实可见时才能使用;
+- “大面积网布形成轻透、通风的视觉感受”可以作为视觉表达,但不能改写成经过测试的透气性能;
+- “分区口袋让收纳关系一目了然”只有口袋及分区真实可见时才能使用。
+
+禁止只写抽象形容词,也禁止从图片推断手感、气味、口感、耐用性、防护能力、功效、认证、真实尺寸、容量、成分或性能。
+
+## 证据引用
+
+- 可见事实:`visible_fact:1`
+- 用户明确事实:`provided_fact:1`
+- 用户原始想法:`user_instruction`
+- VOC:`voc:1`
+- 视觉定位:`creative_positioning:1`
+- 建议人群:`suggested_audience:1`
+- 建议场景:`suggested_scene:1`
+- 可见规格:`visible_spec:<key>`
+- 候选卖点:使用其稳定 `id`
+
+七图计划中的卖点图必须引用不同候选卖点。场景建议只负责创意方向,不能成为商品功能或适用性声明的证据。
+
+计划不得改写视觉审计生成的 `identity_manifest` 和 `verified_visible_facts`。例如参考图没有可见纽扣,就不能在后续计划中用“单排扣”补齐服装术语;正常敞开的衣襟也不能被改写成“前中开衩”。

+ 121 - 0
claude-code/fmode-image-set/skills/fmode-image-set/references/prompt-compiler.md

@@ -0,0 +1,121 @@
+# 逐图 Prompt 编译器
+
+## 原则
+
+Prompt 长度由图位复杂度决定。只加入会改变生成结果的控制信息,不以字数为目标。
+
+先分别写入 `prompt_modules`,再按模块顺序编译 `generation_prompt`。禁止先写一条约 200 字的固定模板,再把内容拆回模块。
+
+## 可选模块
+
+按顺序选择需要的模块:
+
+1. `product_lock`:商品结构、颜色、Logo、标签和包装不可改变项;
+2. `image_job`:本图唯一任务;
+3. `scene`:空间、人物、道具和相互关系;
+4. `composition`:画幅、机位、景别、位置、占比、裁切和留白;
+5. `lighting_material`:光源、方向、软硬、色温、反射、折射、纹理;
+6. `camera_style`:镜头、景深、商业摄影类型和真实度;
+7. `text_layout`:必须出现的文字、位置、层级和安全区;
+8. `negative_constraints`:变形、错误文字、额外物件、水印和不需要的风格。
+
+## 复杂度路由
+
+### 简单图
+
+适用:白底主图、单一角度商品图。
+
+使用商品锁、背景、构图、光线和禁止项即可。通常无需人物、道具、情绪和镜头故事。
+
+指导区间:完整 Prompt 通常 100–320 字符。`scene` 可以很短,但必须明确背景与商品的关系。
+
+### 中等图
+
+适用:材质特写、结构解析、规格、用法和简洁信息图。
+
+补充局部范围、版式、引导线、文字层级、材质表现和安全区。
+
+指导区间:完整 Prompt 通常 220–560 字符。`scene` 至少写清局部范围、其他元素及其关系。
+
+### 复杂图
+
+适用:生活场景、人物、目标人群、氛围广告和多物件平铺。
+
+完整描述场景、人物、道具、空间关系、商品占比、光线、镜头和情绪,但仍只服务一个核心任务。
+
+指导区间:完整 Prompt 通常 360–780 字符。`scene` 必须足以锁定前中后景、人物或道具关系和动作状态;不能因为其他图较短而主动压缩到相同篇幅。
+
+区间用于发现明显欠描述,不要求每条接近上限。复杂度不同的七条 Prompt 不应自然收敛到几乎相同的总长度。
+
+## 商品一致性锁
+
+七条 Prompt 的高权重位置保留必要商品锁,但商品锁只负责身份一致,不承担画面创意。`product_lock` 由视觉适配器根据 `identity_manifest` 确定性生成,七张必须逐字复制,Claude不得自由改写。只锁定来源可靠的信息,不编造背面、接口、尺寸、成分或功效。
+
+`product_lock` 控制在 20–160 字符。复杂图中,场景正文应明显长于商品锁;不得用反复描述商品占满复杂场景的内容预算。`unknown_features` 和 `identity_manifest.unknown_do_not_assert` 中的内容不得进入正向模块。
+
+推荐句式:
+
+```text
+严格保留参考商品的{外形、比例、结构、颜色、Logo、标签和包装细节},不得重新设计商品,不改变文字,不增加或删除结构。
+```
+
+## 用户想法和 VOC
+
+- 用户想法是创意意图,可直接改变相关图位的场景、情绪、道具和人群;
+- VOC 是内部决策依据,只在 `available` 且有具体 finding 时使用;
+- VOC 关注点可转译为视觉场景,不能自动写成商品功效;
+- 无证据的“持久、抗菌、安全、临床、第一、销量”等不得进入 Prompt 或画面文字。
+- 用户给出一个目标人群或场合时,不自动扩展到新的场合;场景道具可以创作,商品适用性结论不能创作。
+- “清冷、轻盈、坚固感、科技感”等用户创意词只描述色彩、道具、空间和情绪,除非资料确认,否则不得改写为真实气味、成分、性能、耐用性或功效结论。
+- `unknowns` 中的包装、配件、容量、结构和文字不得被Prompt补入画面。
+- “细节/工艺图”不等于允许展示未拍到的内部结构;只放大真实可见区域,若需要开盖、背面或内部必须有对应参考图。
+
+## 文字
+
+商品标签必须要求逐字保持。当前Demo不生成额外营销文字,也不依赖生图模型完成信息排版;`overlay_copy`保持空数组。
+
+## 即梦约束
+
+- 最终发送给即梦的单条 Prompt 不超过 800 字符;
+- 不因接口文档的简短建议把复杂场景强行压缩到 120 字;
+- 如果完整 Prompt 超限,先删除重复形容词和低影响修饰,再压缩场景细节;商品锁和构图约束最后删除;
+- 负面词需要合并进最终请求时,保持简短并放在末尾。
+
+## 差异化编译
+
+先读取每张图的 `shot_spec`,再编译场景正文。复杂图至少写清以下关系:
+
+- 商品相对画面边界、前中后景和其他元素的位置;
+- 镜头高度、角度、景别、商品占比、裁切和留白;
+- 主光源的方向、面积、软硬、色温,以及玻璃、金属、织物等材料如何响应;
+- 本图与前后图不同的动作、空间或信息任务。
+
+复杂图还必须明确:
+
+- 前景、中景、后景分别出现什么,哪些区域必须留空;
+- 人物存在时的身份线索、服装、姿态、视线、手部动作及其与商品的距离;
+- 道具的数量、位置、遮挡关系和主次,禁止只列道具名;
+- 环境光与主光的来源、方向、软硬、色温,以及商品和环境材料分别如何响应;
+- 画面最终落点:消费者先看到什么,再看到什么。
+
+当前Demo没有确定性文字合成器,不选择 `structured_information`,也不写“商品加右侧留白”来冒充信息图。购买决策图优先选择多角度、步骤、包装内容、尺度关系或比较序列;没有可靠证据时使用多角度外观或可见结构顺序,不编造数据。
+
+整套最多一张细节特写和一张多格图。其余卖点图、场景图与购买决策图必须使用连续空间、人物动作、完整商品关系或其他不同视觉语法,不能再用近景局部或多面板换皮。
+
+避免只写“高级、奢华、克制、极简、商业摄影”等抽象形容词。每个抽象词必须由可见设计决定来实现,例如非对称留白、低机位、硬边轮廓光、长阴影或材质对比。
+
+白底图可以短;复杂场景应写到足以锁定空间关系,不以统一字数模板截断。统一视觉不等于统一台面、统一居中构图或统一柔光。
+
+商品标签不得与新增文字冲突。需要保留商品标签时,负面词写“额外文字、额外品牌标识”,不要笼统写“文字、Logo”。营销文案和信息排版留待确定性合成器实现。
+
+## 自检问题
+
+- 删除某一句是否会让模型失去重要控制?若不会,删除;
+- 本图是否只有一个主要任务?
+- 商品占比、位置和裁切是否明确?
+- 场景中每个道具是否服务主题?
+- 光线是否符合材质物理特性?
+- 是否把用户愿望或 VOC 当成商品事实?
+- 是否与另外六张图明显重复?
+- 本图删除场景名词后,是否仍能与另一张互换?若能,重写构图和镜头任务;
+- 七张是否形成远近、明暗、动静、疏密变化,而不只是背景换色?