Forráskód Böngészése

feat(fmode-image-set): sync v0.6.0

Yi Jiarui 1 hónapja
szülő
commit
e2270a1865
22 módosított fájl, 555 hozzáadás és 33 törlés
  1. 1 1
      claude-code/fmode-image-set/.claude-plugin/plugin.json
  2. 4 0
      claude-code/fmode-image-set/.env.example
  3. 7 3
      claude-code/fmode-image-set/README.md
  4. 1 1
      claude-code/fmode-image-set/docs/cost-model.md
  5. 50 0
      claude-code/fmode-image-set/mcp/src/core/plan-ref-store.mjs
  6. 28 0
      claude-code/fmode-image-set/mcp/src/core/plan-ref-store.test.mjs
  7. 112 0
      claude-code/fmode-image-set/mcp/src/core/plan-token.mjs
  8. 83 0
      claude-code/fmode-image-set/mcp/src/core/plan-token.test.mjs
  9. 9 4
      claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.mjs
  10. 47 0
      claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.test.mjs
  11. 6 2
      claude-code/fmode-image-set/mcp/src/features/planning/catalog.mjs
  12. 13 0
      claude-code/fmode-image-set/mcp/src/features/planning/catalog.test.mjs
  13. 7 3
      claude-code/fmode-image-set/mcp/src/server.mjs
  14. 36 1
      claude-code/fmode-image-set/mcp/src/tools/generate.mjs
  15. 101 0
      claude-code/fmode-image-set/mcp/src/tools/plan-handoff.test.mjs
  16. 20 4
      claude-code/fmode-image-set/mcp/src/tools/plan.mjs
  17. 2 2
      claude-code/fmode-image-set/package-lock.json
  18. 1 1
      claude-code/fmode-image-set/package.json
  19. 20 4
      claude-code/fmode-image-set/scripts/smoke-mcp.mjs
  20. 1 1
      claude-code/fmode-image-set/skill-package-manifest.json
  21. 4 4
      claude-code/fmode-image-set/skills/fmode-image-set/SKILL.md
  22. 2 2
      claude-code/fmode-image-set/skills/fmode-image-set/references/workflow.md

+ 1 - 1
claude-code/fmode-image-set/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
     "name":  "fmode-image-set",
     "description":  "Plan and generate consistent e-commerce image sets from product facts, references, brand rules, and platform constraints.",
-    "version":  "0.4.0",
+    "version":  "0.6.0",
     "author":  {
                    "name":  "Fmode"
                }

+ 4 - 0
claude-code/fmode-image-set/.env.example

@@ -2,6 +2,10 @@
 FMODE_LLM_BASE_URL=http://server.fmode.cn:9999
 FMODE_LLM_API_KEY=
 
+# Optional dedicated secret for restart-safe encrypted plan tokens. Use at least 24 random characters.
+# When omitted, the process derives a separate token key from FMODE_LLM_API_KEY for compatibility.
+FMODE_PLAN_TOKEN_SECRET=
+
 # Required only for a trusted remote HTTP gateway. Prefer HTTPS whenever available.
 FMODE_ALLOW_INSECURE_HTTP=true
 FMODE_LLM_TIMEOUT_MS=180000

+ 7 - 3
claude-code/fmode-image-set/README.md

@@ -1,5 +1,7 @@
 # Fmode Image Set
 
+> 当前版本:`0.6.0`。在 `0.5.0` 基线上,将计划确认交接升级为跨 MCP 重启、工作区/会话/精确计划绑定的加密 `planToken`;图像质量、模板、Provider 与生成提示词保持不变。
+
 面向 VSCode Claude Code 的商品套图设计包:用 `gemini-3.1-flash-image-preview` 分析商品图和检查质量,先生成可确认的套图计划,再通过 New API 的 Gemini 或 `doubao-seedream-4-0-250828` 生图。需要 Node.js 20.11 或更高版本。
 
 ## 能力
@@ -13,7 +15,7 @@
 - 商品身份锁、颜色锁、风格锁、本地主图 Data URL 锚点和 Gemini 生成后质量复核
 - 国内平台生成结果的 PNG/JPEG/WebP 真实像素、比例、透明通道和规则状态检查
 - 套图内 `quality_rejected` 单图返修自动归位原运行目录,使用版本化文件名并更新原套图状态
-- 计划/执行分离、单图临时异常最多三次自动尝试、失败不阻断后续图片、原 run 指定项续跑
+- 计划/执行分离、短期计划引用稳定衔接、单图临时异常最多三次自动尝试、失败不阻断后续图片、原 run 指定项续跑
 - Gemini 高质量默认生图、Seedream 2K 备选生图、两模型编辑和 Seedream 2K 增强
 
 ## 配置
@@ -55,9 +57,9 @@ D:\项目\商品正面.png、D:\项目\参考图\商品背面.jpg 是 MORI 咖
 ## MCP Tools
 
 - `fmode_image_set_analyze`:Gemini 商品识图。
-- `fmode_image_set_plan`:套图规划和模型选择,不向用户展示生成费用。
+- `fmode_image_set_plan`:套图规划和模型选择,不向用户展示生成费用;传入 `sessionId + promptId` 时返回跨重启有效的加密 `planToken`,并保留旧 `planRef`
 - `fmode_image_set_templates`:查询模板和预设。
-- `fmode_image_set_generate`:确认后生成并接收 Base64 结果;也可在原 run 只重试指定 outputId
+- `fmode_image_set_generate`:确认后以 `planToken + confirmedPlanId + 同一 sessionId` 精确执行并接收 Base64 结果;兼容旧 `planRef`、完整 plan 和原 run 指定 outputId 续跑
 - `fmode_image_set_status`:读取本地运行状态。
 - `fmode_image_set_edit`:Gemini/Seedream 图像编辑;受管理套图中的当前质量拒绝项会原位返修,普通图片仍创建独立编辑运行。
 - `fmode_image_set_upscale`:Seedream 高质量 2K 增强。
@@ -79,6 +81,8 @@ D:\项目\商品正面.png、D:\项目\参考图\商品背面.jpg 是 MORI 咖
 ## 确认、恢复与安全
 
 - 模板查询和本地规划不调用外部模型;生成、编辑和 2K 增强必须显式 `confirmed: true`。
+- 同一商品的多张路径可一次传入并设置 `allImagesSameProduct=true`;混合参考图才需要逐张角色映射。
+- `planToken` 是 AES-256-GCM 加密的短期自包含计划载体,绑定工作区摘要、规划会话和精确 `planId`,不落盘且不替代用户确认;MCP 重启后仍可执行。它用于防止误串台,但当前 MCP 的 `sessionId` 由调用方提供,不能宣称为宿主级不可伪造身份。旧 `planRef` 仅作同进程兼容。
 - 普通用户计划、状态和总结不展示预计、累计或实际费用。内部仍保留可信安全上限;`budgetLimitCny` 只作为旧调用兼容字段。
 - 单图网络中断、临时网关异常或响应无法解析时,使用稳定幂等键最多自动尝试三次;仍失败则继续后续图片,不自动切换模型。
 - 已取得远程结果 URL 时只重试下载,不重新生成。执行结束后列出失败 outputId,用户确认后以原 `runId + retryOutputIds` 续跑。

+ 1 - 1
claude-code/fmode-image-set/docs/cost-model.md

@@ -1,4 +1,4 @@
-# 内部成本安全上限(v0.4.0)
+# 内部成本安全上限(v0.6.0)
 
 > 仅供开发者维护内部执行上限。普通用户计划、状态、错误和总结不得展示本文件中的字段、单价、估算、累计或实际费用。
 

+ 50 - 0
claude-code/fmode-image-set/mcp/src/core/plan-ref-store.mjs

@@ -0,0 +1,50 @@
+import { randomBytes } from 'node:crypto';
+
+const DEFAULT_TTL_MS = 30 * 60 * 1000;
+const DEFAULT_MAX_ENTRIES = 100;
+
+function clone(value) {
+  return structuredClone(value);
+}
+
+export class PlanRefStore {
+  constructor({ ttlMs = DEFAULT_TTL_MS, maxEntries = DEFAULT_MAX_ENTRIES, now = () => Date.now() } = {}) {
+    this.ttlMs = ttlMs;
+    this.maxEntries = maxEntries;
+    this.now = now;
+    this.entries = new Map();
+  }
+
+  purgeExpired(at = this.now()) {
+    for (const [ref, entry] of this.entries) if (entry.expiresAtMs <= at) this.entries.delete(ref);
+  }
+
+  create(plan) {
+    if (!plan?.planId || !plan?.planHash) throw new Error('只能缓存已完成完整性标识的计划。');
+    const createdAtMs = this.now();
+    this.purgeExpired(createdAtMs);
+    while (this.entries.size >= this.maxEntries) this.entries.delete(this.entries.keys().next().value);
+    const planRef = `pref_${randomBytes(24).toString('base64url')}`;
+    const expiresAtMs = createdAtMs + this.ttlMs;
+    this.entries.set(planRef, { plan: clone(plan), expiresAtMs });
+    return { planRef, expiresAt: new Date(expiresAtMs).toISOString() };
+  }
+
+  resolve(planRef) {
+    const ref = String(planRef || '').trim();
+    const at = this.now();
+    const entry = this.entries.get(ref);
+    if (!entry || entry.expiresAtMs <= at) {
+      if (entry) this.entries.delete(ref);
+      throw new Error('计划引用无效或已过期;请重新执行不调用生图 Provider 的本地规划。');
+    }
+    return clone(entry.plan);
+  }
+
+  clear() {
+    this.entries.clear();
+  }
+}
+
+export const defaultPlanRefStore = new PlanRefStore();
+export { DEFAULT_MAX_ENTRIES, DEFAULT_TTL_MS };

+ 28 - 0
claude-code/fmode-image-set/mcp/src/core/plan-ref-store.test.mjs

@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { PlanRefStore } from './plan-ref-store.mjs';
+
+function plan(id) {
+  return { planId: `plan_${id}`, planHash: id.repeat(64).slice(0, 64), items: [{ outputId: 'S1' }] };
+}
+
+test('plan references return an isolated in-memory copy and expire', () => {
+  let clock = 1_000;
+  const store = new PlanRefStore({ ttlMs: 100, now: () => clock });
+  const original = plan('a');
+  const issued = store.create(original);
+  assert.match(issued.planRef, /^pref_[A-Za-z0-9_-]+$/);
+  const resolved = store.resolve(issued.planRef);
+  resolved.items[0].outputId = 'changed';
+  assert.equal(store.resolve(issued.planRef).items[0].outputId, 'S1');
+  clock = 1_100;
+  assert.throws(() => store.resolve(issued.planRef), /无效或已过期/);
+});
+
+test('plan reference store evicts the oldest entry at its capacity limit', () => {
+  const store = new PlanRefStore({ maxEntries: 1 });
+  const first = store.create(plan('a')).planRef;
+  const second = store.create(plan('b')).planRef;
+  assert.throws(() => store.resolve(first), /无效或已过期/);
+  assert.equal(store.resolve(second).planId, 'plan_b');
+});

+ 112 - 0
claude-code/fmode-image-set/mcp/src/core/plan-token.mjs

@@ -0,0 +1,112 @@
+import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { deflateRawSync, inflateRawSync } from 'node:zlib';
+
+const TOKEN_PREFIX = 'ptk1';
+const TOKEN_AAD = Buffer.from('fmode-image-set/plan-token/v1', 'utf8');
+const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
+const MAX_TOKEN_LENGTH = 100_000;
+const MAX_PAYLOAD_BYTES = 512 * 1024;
+
+function text(value) {
+  return String(value || '').trim();
+}
+
+function workspaceDigest(workspaceRoot) {
+  const resolved = path.resolve(workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd());
+  let canonical = resolved;
+  try { canonical = fs.realpathSync.native(resolved); } catch {}
+  if (process.platform === 'win32') canonical = canonical.toLowerCase();
+  return createHash('sha256').update(canonical, 'utf8').digest('base64url');
+}
+
+function deriveKey(secret) {
+  const value = text(secret);
+  if (value.length < 24) throw new Error('计划令牌密钥未配置或长度不足。');
+  return createHash('sha256').update('fmode-image-set/plan-token/key/v1\0', 'utf8').update(value, 'utf8').digest();
+}
+
+function equalText(left, right) {
+  const a = Buffer.from(text(left), 'utf8');
+  const b = Buffer.from(text(right), 'utf8');
+  return a.length === b.length && timingSafeEqual(a, b);
+}
+
+export class PlanTokenCodec {
+  constructor({ secret, ttlMs = DEFAULT_TTL_MS, now = () => Date.now() } = {}) {
+    this.key = deriveKey(secret);
+    this.ttlMs = ttlMs;
+    this.now = now;
+  }
+
+  seal(plan, context = {}) {
+    const sessionId = text(context.sessionId);
+    const promptId = text(context.promptId);
+    if (!plan?.planId || !plan?.planHash) throw new Error('只能签发完整性已标识的计划。');
+    if (!sessionId || !promptId) throw new Error('签发计划令牌必须提供 sessionId 和 promptId。');
+    const issuedAtMs = this.now();
+    const payload = {
+      version: 1,
+      issuedAtMs,
+      expiresAtMs: issuedAtMs + this.ttlMs,
+      nonce: randomBytes(16).toString('base64url'),
+      binding: {
+        workspace: workspaceDigest(context.workspaceRoot),
+        sessionId,
+        planningPromptId: promptId,
+        planId: plan.planId,
+        planHash: plan.planHash
+      },
+      plan: structuredClone(plan)
+    };
+    const plain = Buffer.from(JSON.stringify(payload), 'utf8');
+    if (plain.length > MAX_PAYLOAD_BYTES) throw new Error('计划内容过大,无法签发计划令牌。');
+    const compressed = deflateRawSync(plain);
+    if (compressed.length > MAX_PAYLOAD_BYTES) throw new Error('计划内容过大,无法签发计划令牌。');
+    const iv = randomBytes(12);
+    const cipher = createCipheriv('aes-256-gcm', this.key, iv);
+    cipher.setAAD(TOKEN_AAD);
+    const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
+    const tag = cipher.getAuthTag();
+    const planToken = [TOKEN_PREFIX, iv.toString('base64url'), ciphertext.toString('base64url'), tag.toString('base64url')].join('.');
+    if (planToken.length > MAX_TOKEN_LENGTH) throw new Error('计划内容过大,无法签发计划令牌。');
+    return { planToken, expiresAt: new Date(payload.expiresAtMs).toISOString() };
+  }
+
+  open(planToken, context = {}) {
+    const token = text(planToken);
+    if (!token || token.length > MAX_TOKEN_LENGTH) throw new Error('计划令牌无效、已过期或与当前上下文不匹配。');
+    const parts = token.split('.');
+    if (parts.length !== 4 || parts[0] !== TOKEN_PREFIX) throw new Error('计划令牌无效、已过期或与当前上下文不匹配。');
+    try {
+      const iv = Buffer.from(parts[1], 'base64url');
+      const ciphertext = Buffer.from(parts[2], 'base64url');
+      const tag = Buffer.from(parts[3], 'base64url');
+      if (iv.length !== 12 || tag.length !== 16 || !ciphertext.length || ciphertext.length > MAX_PAYLOAD_BYTES) throw new Error('invalid token shape');
+      const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
+      decipher.setAAD(TOKEN_AAD);
+      decipher.setAuthTag(tag);
+      const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+      const plain = inflateRawSync(compressed, { maxOutputLength: MAX_PAYLOAD_BYTES });
+      const payload = JSON.parse(plain.toString('utf8'));
+      const binding = payload?.binding || {};
+      if (payload?.version !== 1 || !payload?.plan || Number(payload.expiresAtMs) <= this.now()) throw new Error('expired token');
+      if (!equalText(binding.workspace, workspaceDigest(context.workspaceRoot))) throw new Error('workspace mismatch');
+      if (!equalText(binding.sessionId, context.sessionId)) throw new Error('session mismatch');
+      if (!equalText(binding.planId, context.confirmedPlanId)) throw new Error('plan mismatch');
+      if (!equalText(binding.planId, payload.plan.planId) || !equalText(binding.planHash, payload.plan.planHash)) throw new Error('payload mismatch');
+      return structuredClone(payload.plan);
+    } catch {
+      throw new Error('计划令牌无效、已过期或与当前上下文不匹配。');
+    }
+  }
+}
+
+export function configuredPlanTokenCodec(env = process.env, options = {}) {
+  const secret = text(env.FMODE_PLAN_TOKEN_SECRET || env.FMODE_LLM_API_KEY || env.LLM_API_KEY);
+  if (!secret) return null;
+  return new PlanTokenCodec({ ...options, secret });
+}
+
+export { DEFAULT_TTL_MS as DEFAULT_PLAN_TOKEN_TTL_MS, MAX_PAYLOAD_BYTES, MAX_TOKEN_LENGTH, workspaceDigest };

+ 83 - 0
claude-code/fmode-image-set/mcp/src/core/plan-token.test.mjs

@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import test from 'node:test';
+import { PlanTokenCodec, workspaceDigest } from './plan-token.mjs';
+
+const secret = 'test-only-plan-token-secret-32-bytes';
+const workspaceRoot = process.cwd();
+const plan = { planId: 'plan_1234567890abcdef', planHash: 'a'.repeat(64), items: [{ outputId: 'S1' }] };
+
+test('plan token is stateless across codec instances and bound to workspace, session and confirmed plan', () => {
+  let clock = 1000;
+  const issuer = new PlanTokenCodec({ secret, ttlMs: 100, now: () => clock });
+  const issued = issuer.seal(plan, { workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt' });
+  assert.match(issued.planToken, /^ptk1\./);
+
+  const restarted = new PlanTokenCodec({ secret, ttlMs: 100, now: () => clock });
+  assert.deepEqual(restarted.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), plan);
+  assert.throws(() => restarted.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-b', confirmedPlanId: plan.planId
+  }), /上下文/);
+  assert.throws(() => restarted.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: 'plan_other'
+  }), /上下文/);
+  assert.throws(() => restarted.open(issued.planToken, {
+    workspaceRoot: `${workspaceRoot}-other`, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+});
+
+test('plan token rejects tampering, wrong keys and expiry without revealing which check failed', () => {
+  let clock = 2000;
+  const issuer = new PlanTokenCodec({ secret, ttlMs: 5, now: () => clock });
+  const issued = issuer.seal(plan, { workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt' });
+  const parts = issued.planToken.split('.');
+  parts[2] = `${parts[2].slice(0, -1)}${parts[2].endsWith('A') ? 'B' : 'A'}`;
+  assert.throws(() => issuer.open(parts.join('.'), {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+  const wrongKey = new PlanTokenCodec({ secret: 'another-test-plan-token-secret-32', now: () => clock });
+  assert.throws(() => wrongKey.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+  clock = 2005;
+  assert.throws(() => issuer.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+});
+
+test('workspace digest is stable for the same resolved workspace', () => {
+  assert.equal(workspaceDigest(workspaceRoot), workspaceDigest(`${workspaceRoot}/.`));
+});
+
+test('plan token rejects highly compressible plaintext above the decode limit at issuance', () => {
+  const issuer = new PlanTokenCodec({ secret });
+  const oversized = { ...plan, request: 'x'.repeat(600_000) };
+  assert.throws(() => issuer.seal(oversized, {
+    workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt'
+  }), /过大/);
+});
+
+test('plan token survives a real Node process restart', () => {
+  const issuer = new PlanTokenCodec({ secret });
+  const issued = issuer.seal(plan, { workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt' });
+  const moduleUrl = new URL('./plan-token.mjs', import.meta.url).href;
+  const script = `
+    import fs from 'node:fs';
+    import { PlanTokenCodec } from ${JSON.stringify(moduleUrl)};
+    const input = JSON.parse(fs.readFileSync(0, 'utf8'));
+    const codec = new PlanTokenCodec({ secret: process.env.TEST_PLAN_TOKEN_SECRET });
+    process.stdout.write(JSON.stringify(codec.open(input.planToken, input.context)));
+  `;
+  const child = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
+    input: JSON.stringify({
+      planToken: issued.planToken,
+      context: { workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId }
+    }),
+    encoding: 'utf8',
+    env: { ...process.env, TEST_PLAN_TOKEN_SECRET: secret }
+  });
+  assert.equal(child.status, 0, child.stderr);
+  assert.deepEqual(JSON.parse(child.stdout), plan);
+});

+ 9 - 4
claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.mjs

@@ -51,18 +51,23 @@ export async function analyzeProductImages(input = {}, dependencies = {}) {
   }
   let referenceRoles = [];
   const allowedRoles = new Set(['identity', 'style', 'detail', 'model']);
-  if (images.length > 1 && !resolvedInput.imageRoles) {
+  if (images.length > 1 && resolvedInput.allImagesSameProduct === true && resolvedInput.imageRoles) {
+    return errorResult('needs_input', 'allImagesSameProduct 与 imageRoles 只能传一个;同一商品多视图使用前者,混合参考图使用后者。');
+  }
+  if (images.length > 1 && resolvedInput.allImagesSameProduct === true) {
+    referenceRoles = images.map((image, index) => ({ image: index + 1, label: image.label, role: 'identity' }));
+  } else if (images.length > 1 && !resolvedInput.imageRoles) {
     return errorResult('needs_input', '检测到多张图片,请先标注每张图是 identity、style、detail 还是 model。', {
       summary: { imageCount: images.length },
-      nextActions: ['传入覆盖每张图片的 imageRoles。']
+      nextActions: ['同一商品的多张视图传 allImagesSameProduct=true;混合参考图传覆盖每张图片的 imageRoles,例如 {"1":"identity","2":"style"}。']
     });
   }
-  if (images.length > 1 && resolvedInput.imageRoles) {
+  if (images.length > 1 && resolvedInput.allImagesSameProduct !== true && resolvedInput.imageRoles) {
     for (let index = 0; index < images.length; index += 1) {
       const image = images[index];
       const role = resolvedInput.imageRoles[String(index + 1)] || resolvedInput.imageRoles[image.label];
       if (!allowedRoles.has(role)) {
-        return errorResult('needs_input', `第 ${index + 1} 张图片缺少有效角色;必须覆盖每张图并使用 identity、style、detail 或 model。`);
+        return errorResult('needs_input', `第 ${index + 1} 张图片缺少有效角色;请用编号键覆盖每张图,例如 {"1":"identity","2":"detail"},角色只能是 identity、style、detail 或 model。`);
       }
       referenceRoles.push({ image: index + 1, label: image.label, role });
     }

+ 47 - 0
claude-code/fmode-image-set/mcp/src/features/image-analysis/analyze.test.mjs

@@ -66,6 +66,53 @@ test('analysis requires a complete role mapping for multiple images and passes i
   }
 });
 
+test('analysis accepts one explicit same-product flag for multiple identity views', async () => {
+  const workspace = tempWorkspace('same-product');
+  const files = ['main.png', 'front.png', 'back.png'].map((name, index) => {
+    const file = path.join(workspace, name);
+    fs.writeFileSync(file, Buffer.from([137, 80, 78, 71 + index]));
+    return file;
+  });
+  let received;
+  const provider = { analyze: async (_images, input) => {
+    received = input.imageRoles;
+    return { model: 'fake', usage: {}, finishReason: 'stop', analysis: {} };
+  } };
+  try {
+    const result = await analyzeProductImages({
+      images: files, allImagesSameProduct: true, allowClaudeAttachments: false
+    }, { provider, workspaceRoot: workspace });
+    assert.equal(result.status, 'ok');
+    assert.deepEqual(received.map(item => item.role), ['identity', 'identity', 'identity']);
+    assert.deepEqual(result.data.referenceRoles.map(item => item.label), ['main.png', 'front.png', 'back.png']);
+  } finally {
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
+});
+
+test('analysis rejects ambiguous same-product and per-image role inputs', async () => {
+  const workspace = tempWorkspace('ambiguous-roles');
+  const files = ['first.png', 'second.png'].map((name, index) => {
+    const file = path.join(workspace, name);
+    fs.writeFileSync(file, Buffer.from([137, 80, 78, 71 + index]));
+    return file;
+  });
+  let called = false;
+  try {
+    const result = await analyzeProductImages({
+      images: files,
+      allImagesSameProduct: true,
+      imageRoles: { 1: 'identity', 2: 'style' },
+      allowClaudeAttachments: false
+    }, { workspaceRoot: workspace, provider: { analyze: async () => { called = true; } } });
+    assert.equal(result.status, 'needs_input');
+    assert.match(result.assistantMessage, /只能传一个/);
+    assert.equal(called, false);
+  } finally {
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
+});
+
 test('analysis rejects a local image outside the declared workspace', async () => {
   const parent = tempWorkspace('outside-boundary');
   const workspace = path.join(parent, 'workspace');

+ 6 - 2
claude-code/fmode-image-set/mcp/src/features/planning/catalog.mjs

@@ -30,8 +30,12 @@ export function searchTemplates(query = '', locale = 'zh') {
   const text = String(query || '').trim().toLowerCase();
   const all = listTemplates(locale);
   if (!text) return all;
-  return all.filter(template => [template.id, template.name, ...(template.keywords || []), ...(template.trigger_phrases || [])]
-    .some(value => String(value).toLowerCase().includes(text)));
+  const terms = [...new Set(text.split(/\s+/).filter(Boolean))];
+  return all.filter(template => {
+    const searchable = [template.id, template.name, ...(template.keywords || []), ...(template.trigger_phrases || [])]
+      .map(value => String(value).toLowerCase());
+    return terms.some(term => searchable.some(value => value.includes(term)));
+  });
 }
 
 export { catalogRoot, templateRoot };

+ 13 - 0
claude-code/fmode-image-set/mcp/src/features/planning/catalog.test.mjs

@@ -0,0 +1,13 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { searchTemplates } from './catalog.mjs';
+
+test('template search tokenizes combined natural-language queries', () => {
+  const results = searchTemplates('A+ Amazon 详情页模块', 'zh');
+  assert.ok(results.some(template => template.id === 'infographic'));
+});
+
+test('single-term template search remains compatible', () => {
+  assert.ok(searchTemplates('A+', 'zh').some(template => template.id === 'infographic'));
+  assert.ok(searchTemplates('detail-macro', 'en').some(template => template.id === 'detail-macro'));
+});

+ 7 - 3
claude-code/fmode-image-set/mcp/src/server.mjs

@@ -11,7 +11,7 @@ import { runStatus } from './tools/status.mjs';
 import { runTemplates } from './tools/templates.mjs';
 import { runUpscale } from './tools/upscale.mjs';
 
-export const VERSION = '0.4.0';
+export const VERSION = '0.6.0';
 export const TOOL_NAMES = [
   'fmode_image_set_analyze', 'fmode_image_set_plan', 'fmode_image_set_templates',
   'fmode_image_set_generate', 'fmode_image_set_status', 'fmode_image_set_edit', 'fmode_image_set_upscale'
@@ -35,6 +35,7 @@ export function createServer() {
     inputSchema: {
       imagePath: z.string().optional(), imageUrl: z.string().optional(), images: z.array(z.unknown()).optional(),
       imageRoles: z.record(z.string(), z.string()).optional(),
+      allImagesSameProduct: z.boolean().optional().describe('Set true only when every supplied image is a view of the same product; all images become identity references.'),
       allowClaudeAttachments: z.boolean().optional(), sessionId: z.string().optional(), promptId: z.string().optional(),
       locale: z.enum(['zh', 'en']).optional(),
       prompt: z.string().optional()
@@ -49,7 +50,9 @@ export function createServer() {
       productName: z.string().max(120).optional(),
       category: z.string().optional(), platform: z.string().optional(), platforms: z.array(z.string()).optional(),
       style: z.string().optional(), brandColors: z.array(z.string()).optional(), requestedCopy: z.string().optional(), count: z.number().int().min(1).max(14).optional(),
-      modelPreference: z.enum(['high_quality', 'gemini', 'seedream']).optional(), qualityReview: z.boolean().optional()
+      modelPreference: z.enum(['high_quality', 'gemini', 'seedream']).optional(), qualityReview: z.boolean().optional(),
+      sessionId: z.string().optional().describe('Conversation identifier used to bind a restart-safe planToken.'),
+      promptId: z.string().optional().describe('Planning prompt identifier recorded inside the planToken for audit correlation.')
     }, outputSchema, annotations: readOnly
   }, async input => asToolResult(await runPlan(input)));
 
@@ -62,7 +65,8 @@ export function createServer() {
   server.registerTool('fmode_image_set_generate', {
     description: 'Execute a confirmed image-set plan, or retry only selected failed outputIds in an existing run. Transient single-image failures are retried automatically and do not block later outputs. On full success, root-level local references are moved and nested references are copied into the Chinese run directory.',
     inputSchema: {
-      plan: z.record(z.string(), z.unknown()).optional(), runId: z.string().optional(),
+      plan: z.record(z.string(), z.unknown()).optional(), planRef: z.string().min(16).optional(),
+      planToken: z.string().min(32).max(100000).optional(), confirmedPlanId: z.string().min(8).max(120).optional(), runId: z.string().optional(),
       retryOutputIds: z.array(z.string()).optional(), confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative().optional(),
       sessionId: z.string(), promptId: z.string(), referenceImages: z.array(z.unknown()).optional(),
       qualityReview: z.boolean().optional(), allowClaudeAttachments: z.boolean().optional()

+ 36 - 1
claude-code/fmode-image-set/mcp/src/tools/generate.mjs

@@ -1,8 +1,43 @@
 import { executePlan, retryRunItems } from '../features/generation/orchestrator.mjs';
+import { defaultPlanRefStore } from '../core/plan-ref-store.mjs';
+import { configuredPlanTokenCodec } from '../core/plan-token.mjs';
+import { errorResult } from '../core/result-envelope.mjs';
+import { safeError } from '../core/redaction.mjs';
 
 export async function runGenerate(input = {}, dependencies = {}) {
   if (input.runId && (input.retryOutputIds || input.retryFailedOutputIds)) {
     return retryRunItems(input, dependencies);
   }
-  return executePlan({ ...input, organizeSources: true }, dependencies);
+  const carriers = [input.plan, input.planRef, input.planToken].filter(Boolean).length;
+  if (carriers > 1) {
+    return errorResult('needs_input', 'plan、planRef 与 planToken 只能传一个;确认后优先使用 planToken。');
+  }
+  let plan = input.plan;
+  if (!plan && input.planToken) {
+    if (!input.confirmedPlanId) return errorResult('needs_input', '使用 planToken 必须明确传入用户确认的 confirmedPlanId。');
+    try {
+      const planTokenCodec = dependencies.planTokenCodec === undefined ? configuredPlanTokenCodec() : dependencies.planTokenCodec;
+      if (!planTokenCodec) throw new Error('计划令牌密钥不可用。');
+      plan = planTokenCodec.open(input.planToken, {
+        confirmedPlanId: input.confirmedPlanId,
+        sessionId: input.sessionId || input.session_id,
+        workspaceRoot: dependencies.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd()
+      });
+    } catch (error) {
+      return errorResult('needs_input', safeError(error), {
+        nextActions: ['在原对话中使用该计划返回的精确 planToken 和 planId;不要按“最新计划”猜测。']
+      });
+    }
+  }
+  if (!plan && input.planRef) {
+    try {
+      plan = (dependencies.planRefStore || defaultPlanRefStore).resolve(input.planRef);
+    } catch (error) {
+      return errorResult('needs_input', safeError(error), {
+        nextActions: ['重新调用 fmode_image_set_plan;该本地规划不会调用生图 Provider。']
+      });
+    }
+  }
+  if (!plan) return errorResult('needs_input', '新运行必须传 planToken、planRef 或完整 plan。');
+  return executePlan({ ...input, plan, organizeSources: true }, dependencies);
 }

+ 101 - 0
claude-code/fmode-image-set/mcp/src/tools/plan-handoff.test.mjs

@@ -0,0 +1,101 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { PlanRefStore } from '../core/plan-ref-store.mjs';
+import { PlanTokenCodec } from '../core/plan-token.mjs';
+import { runGenerate } from './generate.mjs';
+import { runPlan } from './plan.mjs';
+
+test('plan reference hands the exact public plan to generate without paid execution before confirmation', async () => {
+  const planRefStore = new PlanRefStore();
+  const planned = await runPlan({ request: '规划七张测试套图' }, { planRefStore });
+  assert.equal(planned.status, 'ok');
+  assert.match(planned.data.planRef, /^pref_/);
+  const generated = await runGenerate({
+    planRef: planned.data.planRef,
+    confirmed: false,
+    sessionId: 'session',
+    promptId: 'prompt'
+  }, { planRefStore });
+  assert.equal(generated.status, 'needs_confirmation');
+  assert.equal(generated.data.plan.planHash, planned.data.plan.planHash);
+});
+
+test('restart-safe plan token restores the exact plan in a fresh process context', async () => {
+  const secret = 'test-only-plan-token-secret-32-bytes';
+  const workspaceRoot = process.cwd();
+  const planned = await runPlan({
+    request: '规划七张测试套图', sessionId: 'session-a', promptId: 'planning-prompt'
+  }, {
+    planRefStore: new PlanRefStore(),
+    planTokenCodec: new PlanTokenCodec({ secret }),
+    workspaceRoot
+  });
+  assert.equal(planned.status, 'ok');
+  assert.match(planned.data.planToken, /^ptk1\./);
+
+  const generated = await runGenerate({
+    planToken: planned.data.planToken,
+    confirmedPlanId: planned.data.plan.planId,
+    confirmed: false,
+    sessionId: 'session-a',
+    promptId: 'confirmation-prompt'
+  }, {
+    planRefStore: new PlanRefStore(),
+    planTokenCodec: new PlanTokenCodec({ secret }),
+    workspaceRoot
+  });
+  assert.equal(generated.status, 'needs_confirmation');
+  assert.equal(generated.data.plan.planHash, planned.data.plan.planHash);
+});
+
+test('plan token rejects cross-session, wrong-plan and ambiguous carriers before execution', async () => {
+  const secret = 'test-only-plan-token-secret-32-bytes';
+  const workspaceRoot = process.cwd();
+  const codec = new PlanTokenCodec({ secret });
+  const first = await runPlan({
+    request: '第一个七图计划', sessionId: 'session-a', promptId: 'prompt-a'
+  }, { planTokenCodec: codec, planRefStore: new PlanRefStore(), workspaceRoot });
+  const second = await runPlan({
+    request: '第二个详情计划', preset: 'detail-page', sessionId: 'session-a', promptId: 'prompt-b'
+  }, { planTokenCodec: codec, planRefStore: new PlanRefStore(), workspaceRoot });
+  assert.notEqual(first.data.plan.planId, second.data.plan.planId);
+
+  for (const input of [
+    { sessionId: 'session-b', confirmedPlanId: first.data.plan.planId },
+    { sessionId: 'session-a', confirmedPlanId: second.data.plan.planId }
+  ]) {
+    const rejected = await runGenerate({
+      planToken: first.data.planToken, confirmed: true, promptId: 'confirmation', ...input
+    }, { planTokenCodec: new PlanTokenCodec({ secret }), workspaceRoot });
+    assert.equal(rejected.status, 'needs_input');
+  }
+  const missingPlan = await runGenerate({
+    planToken: first.data.planToken, confirmed: true, sessionId: 'session-a', promptId: 'confirmation'
+  }, { planTokenCodec: new PlanTokenCodec({ secret }), workspaceRoot });
+  assert.equal(missingPlan.status, 'needs_input');
+  const ambiguous = await runGenerate({
+    planToken: first.data.planToken, planRef: first.data.planRef,
+    confirmedPlanId: first.data.plan.planId, confirmed: true, sessionId: 'session-a', promptId: 'confirmation'
+  }, { planTokenCodec: new PlanTokenCodec({ secret }), workspaceRoot });
+  assert.equal(ambiguous.status, 'needs_input');
+});
+
+test('generate keeps full-plan compatibility and rejects ambiguous or expired references', async () => {
+  let clock = 10;
+  const planRefStore = new PlanRefStore({ ttlMs: 5, now: () => clock });
+  const planned = await runPlan({ request: '规划七张测试套图' }, { planRefStore });
+  const legacy = await runGenerate({
+    plan: planned.data.plan, confirmed: false, sessionId: 'session', promptId: 'legacy'
+  }, { planRefStore });
+  assert.equal(legacy.status, 'needs_confirmation');
+  const ambiguous = await runGenerate({
+    plan: planned.data.plan, planRef: planned.data.planRef, confirmed: false, sessionId: 'session', promptId: 'ambiguous'
+  }, { planRefStore });
+  assert.equal(ambiguous.status, 'needs_input');
+  clock = 15;
+  const expired = await runGenerate({
+    planRef: planned.data.planRef, confirmed: true, sessionId: 'session', promptId: 'expired'
+  }, { planRefStore });
+  assert.equal(expired.status, 'needs_input');
+  assert.match(expired.assistantMessage, /本地规划/);
+});

+ 20 - 4
claude-code/fmode-image-set/mcp/src/tools/plan.mjs

@@ -1,16 +1,32 @@
 import { errorResult, okResult } from '../core/result-envelope.mjs';
 import { safeError } from '../core/redaction.mjs';
+import { defaultPlanRefStore } from '../core/plan-ref-store.mjs';
+import { configuredPlanTokenCodec } from '../core/plan-token.mjs';
 import { buildImageSetPlan, toPublicPlan } from '../features/planning/planner.mjs';
 
-export async function runPlan(input = {}) {
+export async function runPlan(input = {}, dependencies = {}) {
   try {
     const plan = buildImageSetPlan(input);
     const publicPlan = toPublicPlan(plan);
+    const planRefStore = dependencies.planRefStore || defaultPlanRefStore;
+    const { planRef, expiresAt: planRefExpiresAt } = planRefStore.create(publicPlan);
+    const planTokenCodec = dependencies.planTokenCodec === undefined ? configuredPlanTokenCodec() : dependencies.planTokenCodec;
+    let token = {};
+    if (planTokenCodec && input.sessionId && input.promptId) {
+      const { planToken, expiresAt: planTokenExpiresAt } = planTokenCodec.seal(publicPlan, {
+        sessionId: input.sessionId,
+        promptId: input.promptId,
+        workspaceRoot: dependencies.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd()
+      });
+      token = { planToken, planTokenExpiresAt };
+    }
     return okResult({
       assistantMessage: publicPlan.confirmationMessage,
-      summary: { planId: plan.planId, preset: plan.preset, outputs: plan.items.length, requiresConfirmation: true },
-      data: { plan: publicPlan },
-      nextActions: ['逐项审核目标、模型、尺寸和提示词。', '确认后以 confirmed=true 调用 fmode_image_set_generate。']
+      summary: { planId: plan.planId, planTokenAvailable: Boolean(token.planToken), planRef, preset: plan.preset, outputs: plan.items.length, requiresConfirmation: true },
+      data: { plan: publicPlan, ...token, planRef, planRefExpiresAt },
+      nextActions: ['逐项审核目标、模型、尺寸和提示词。', token.planToken
+        ? '确认后以 planToken、confirmedPlanId、同一 sessionId 和 confirmed=true 调用 fmode_image_set_generate。'
+        : '当前未签发跨重启计划令牌;确认后可使用 planRef,完整 plan 仍兼容。']
     });
   } catch (error) {
     return errorResult('needs_input', safeError(error));

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

@@ -1,12 +1,12 @@
 {
   "name": "fmode-image-set",
-  "version": "0.4.0",
+  "version": "0.6.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "fmode-image-set",
-      "version": "0.4.0",
+      "version": "0.6.0",
       "license": "MIT",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.29.0",

+ 1 - 1
claude-code/fmode-image-set/package.json

@@ -1,6 +1,6 @@
 {
   "name": "fmode-image-set",
-  "version": "0.4.0",
+  "version": "0.6.0",
   "description": "Claude Code product image-set planning and generation skills with New API Gemini and Seedream models.",
   "type": "module",
   "bin": {

+ 20 - 4
claude-code/fmode-image-set/scripts/smoke-mcp.mjs

@@ -4,7 +4,11 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
 import manifest from '../skill-package-manifest.json' with { type: 'json' };
 
 const root = path.resolve(import.meta.dirname, '..');
-const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(root, 'mcp', 'src', 'server.mjs')], cwd: root, stderr: 'pipe' });
+const transport = new StdioClientTransport({
+  command: process.execPath,
+  args: [path.join(root, 'mcp', 'src', 'server.mjs')], cwd: root, stderr: 'pipe',
+  env: { ...process.env, FMODE_PLAN_TOKEN_SECRET: 'smoke-only-plan-token-secret-32-bytes' }
+});
 const client = new Client({ name: 'fmode-image-set-smoke', version: '0.1.0' });
 try {
   await client.connect(transport);
@@ -17,10 +21,22 @@ try {
     if (!tool.outputSchema) throw new Error(`Missing output schema: ${tool.name}`);
     if (!tool.annotations) throw new Error(`Missing annotations: ${tool.name}`);
   }
-  const response = await client.callTool({ name: 'fmode_image_set_plan', arguments: { request: '规划七张测试套图' } });
+  const response = await client.callTool({
+    name: 'fmode_image_set_plan',
+    arguments: { request: '规划七张测试套图', sessionId: 'smoke-session', promptId: 'smoke-planning-prompt' }
+  });
   const parsed = JSON.parse(response.content[0].text);
-  if (parsed.status !== 'ok' || parsed.data.plan.items.length !== 7) throw new Error('MCP planning call failed.');
-  console.log(JSON.stringify({ status: 'ok', tools: names.length, planId: parsed.data.plan.planId }, null, 2));
+  if (parsed.status !== 'ok' || parsed.data.plan.items.length !== 7 || !parsed.data.planToken) throw new Error('MCP planning call failed.');
+  const confirmation = await client.callTool({
+    name: 'fmode_image_set_generate',
+    arguments: {
+      planToken: parsed.data.planToken, confirmedPlanId: parsed.data.plan.planId,
+      confirmed: false, sessionId: 'smoke-session', promptId: 'smoke-confirmation-prompt'
+    }
+  });
+  const confirmationParsed = JSON.parse(confirmation.content[0].text);
+  if (confirmationParsed.status !== 'needs_confirmation') throw new Error('MCP plan-reference handoff failed.');
+  console.log(JSON.stringify({ status: 'ok', tools: names.length, planId: parsed.data.plan.planId, planToken: true }, null, 2));
 } finally {
   await client.close();
 }

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

@@ -1,6 +1,6 @@
 {
     "name":  "fmode-image-set",
-    "version":  "0.4.0",
+    "version":  "0.6.0",
     "description":  "Claude Code native product image-set planning, analysis, generation, editing, and recovery package.",
     "plugin":  "fmode-image-set",
     "skills":  [

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

@@ -9,10 +9,10 @@ description: Plan and generate consistent e-commerce product image sets with Gem
 
 ## 必须执行
 
-1. 用户在对话中粘贴当前项目内的图片路径时,直接把这些路径传给 `fmode_image_set_analyze`;不要 Glob 搜图、猜测历史图片或要求改用 VSCode 上传。相对路径按当前项目根解析。
+1. 用户在对话中粘贴当前项目内的图片路径时,直接把这些路径传给 `fmode_image_set_analyze`;不要 Glob 搜图、猜测历史图片或要求改用 VSCode 上传。相对路径按当前项目根解析。用户明确这些路径都是同一商品的不同视图时,传 `allImagesSameProduct=true`,不要构造逐张 `imageRoles`;只有混合参考类型时才传完整编号映射,例如 `{"1":"identity","2":"style"}`。
 2. 新任务生成前必须调用 `fmode_image_set_plan`;已有 `runId` 的失败续跑禁止再次识图或规划。计划阶段不得调用生图接口。
 3. 展示计划的张数、逐图用途、模型、尺寸和主要约束,不向用户展示预计、累计或实际生成费用;默认 `high_quality` 使用 Gemini,只有用户明确选择 2K 备选时才用 Seedream。
-4. 只有用户明确确认计划后,才调用 `fmode_image_set_generate`,并传入 `confirmed=true`、`sessionId`、`promptId`;`budgetLimitCny` 是旧调用兼容字段,新流程无需展示或要求用户填写。本次显式商品图路径必须继续通过 `referenceImages` 传入。
+4. 调用 `fmode_image_set_plan` 时同时传当前 `sessionId` 和 `promptId`。只有用户明确确认计划后,才调用 `fmode_image_set_generate`:把 plan 返回的 `data.planToken` 与 `data.plan.planId` 原样分别传为 `planToken` 和 `confirmedPlanId`,同时传相同 `sessionId`、当前确认请求的 `promptId` 与 `confirmed=true`。不要按“最新计划”猜测,不要转写、重建或精简完整 plan。该 token 绑定规划会话、工作区和精确计划,可跨 MCP 重启;旧 `planRef` 和完整 `data.plan` 仅作兼容。`budgetLimitCny` 是旧调用兼容字段,新流程无需展示或要求用户填写。本次显式商品图路径必须继续通过 `referenceImages` 传入。
 5. 单图遇到超时、连接中断、临时网关异常、坏响应或成功响应无图片时,由执行器使用稳定幂等键最多自动尝试 3 次;仍失败则记录该 outputId 并继续后续图片,不自动换模型。
 6. 任务结束后一次性列出失败/拒绝/待恢复 outputId 并询问是否补图。用户确认后必须用原 `runId + retryOutputIds + confirmed=true` 只重试这些项;不得新建计划、重新生成已完成项或为绕过状态更换 run。
 7. 工作区安装器通过受信环境变量 `FMODE_WORKSPACE_ROOT` 固定当前工作区;不要让 Tool 参数改写工作区、Claude 配置目录或输出根路径。
@@ -27,7 +27,7 @@ description: Plan and generate consistent e-commerce product image sets with Gem
 - 把 `visual_inferences` 明确保留为推断,不升级为商品事实。
 - 不从图片臆造功效、认证、成分、尺寸、价格、性能或法律声明。
 - 多图必须明确 identity、style、detail、model 等角色;无法区分时先询问一个最关键问题。
-- 用户明确说明多张路径都是同一商品的商品图时,将这些图片全部标记为 `identity`,不再要求逐张确认;只有混入风格、模特、细节或竞品参考且无法判断时才询问。
+- 用户明确说明多张路径都是同一商品的商品图时,传 `allImagesSameProduct=true`,将这些图片全部标记为 `identity`,不再要求逐张确认;只有混入风格、模特、细节或竞品参考且无法判断时才询问并传完整 `imageRoles`
 - 最新请求无图时不得复用旧请求附件。
 
 ## 工作流
@@ -41,7 +41,7 @@ description: Plan and generate consistent e-commerce product image sets with Gem
    - 主图、副图、A+、多角度、广告、社媒请求使用对应 preset。
    - 淘宝/天猫、京东、拼多多商品图片请求必须显式传 `platform`;逐项展示 `platformRule` 的资产用途、核验状态和警告。安全降级只能称为保守建议,不能称为官方合规。
 4. 向用户输出计划摘要,不把内部完整提示词全部铺开;用户要求时再展示逐项详情。
-5. 收到明确确认后调用 `fmode_image_set_generate`;执行器自行处理单图临时异常并继续整个计划,不要求用户持续盯守。
+5. 收到明确确认后,使用 plan 返回的 `planToken + planId` 精确调用 `fmode_image_set_generate`;`planRef` 和完整 plan 仅作为旧调用兼容。执行器自行处理单图临时异常并继续整个计划,不要求用户持续盯守。
 6. 返回中文运行目录、已完成文件绝对路径、失败 outputId 和下一步,不输出生成费用。完整成功时逐项告知商品原图的整理结果:项目根直属图片已移动到 `商品原图/`,项目子目录图片已复制到 `商品原图/`。
 7. 用户确认补图时,直接调用 `fmode_image_set_generate` 并传原 `runId`、明确的 `retryOutputIds`、新的当前请求 `sessionId/promptId` 和 `confirmed=true`;不要再次调用 analyze/plan。修改普通已有图用 `fmode_image_set_edit`;高质量 2K 增强用 `fmode_image_set_upscale`,两者同样要求明确确认。
    - 质量拒绝项属于当前套图时,必须把该 item 当前 `files` 中的绝对路径原样传给 edit。Tool 会核对父目录 `state.json` 与 outputId,把结果以 `<outputId>-v<版本>-1.<扩展名>` 放回原套图并更新原 item/run;不得手工猜测 run。

+ 2 - 2
claude-code/fmode-image-set/skills/fmode-image-set/references/workflow.md

@@ -11,7 +11,7 @@
 
 ## 1. 输入门禁
 
-最少需要商品描述或一张商品图。用户可直接在对话中粘贴当前项目内图片路径;禁止通过 Glob 或历史文件猜图。用户明确多张图片均为同一商品时全部作为 identity,混合参考类型且无法判断时再确认 identity/style/detail/model。平台不明确时默认通用电商;付费广告和强平台规范场景必须明确平台。
+最少需要商品描述或一张商品图。用户可直接在对话中粘贴当前项目内图片路径;禁止通过 Glob 或历史文件猜图。用户明确多张图片均为同一商品时传 `allImagesSameProduct=true` 并全部作为 identity;混合参考类型且无法判断时再确认 identity/style/detail/model,并以 `{"1":"identity","2":"style"}` 形式覆盖每张图。平台不明确时默认通用电商;付费广告和强平台规范场景必须明确平台。
 
 淘宝/天猫、京东、拼多多商品图片必须显式传平台。国内平台规则按资产类型附加到计划项;官方证据不足的数值保留为安全建议并输出警告,不得描述成官方合规结论。
 
@@ -38,7 +38,7 @@
 
 ## 4. 计划确认
 
-确认项:张数、平台、模型、尺寸、用途、商品事实、品牌约束和必要文字。普通用户不查看或填写生成费用。只有新计划的关键设计项改变才重新生成计划并产生新的 `planHash`;原 run 失败项续跑不是新计划。
+确认项:张数、平台、模型、尺寸、用途、商品事实、品牌约束和必要文字。普通用户不查看或填写生成费用。调用 plan 时传当前 `sessionId + promptId`,取得跨 MCP 重启有效的加密 `planToken`。确认后必须把该 token 与其精确 `planId` 传给 generate,同时使用相同 `sessionId`;不得按“当前会话最新计划”选择。token 绑定工作区、规划会话和计划标识,不落盘,也不替代 `confirmed=true`。旧进程内 `planRef` 与完整 plan 仅作兼容。只有关键设计项改变才重新规划并产生新的 `planHash`;原 run 失败项续跑不是新计划。
 
 ## 5. 生成与恢复