Просмотр исходного кода

update sub-agent control in procedure-dsl

guantao 8 часов назад
Родитель
Сommit
9f7b0e0dc3

+ 0 - 0
examples/process_pipeline/script/procedure-dsl/input/case-multi-raw.json → examples/process_pipeline/script/procedure-dsl/input/case-12.json


+ 0 - 0
examples/process_pipeline/script/procedure-dsl/input/gzh_9ccfa761fa3f0bee8a52bd1038fa007f.json → examples/process_pipeline/script/procedure-dsl/input/case-13.json


+ 0 - 0
examples/process_pipeline/script/procedure-dsl/input/gzh_17fa1c0c888088fde2115b59c3d7d683.json → examples/process_pipeline/script/procedure-dsl/input/case-14.json


+ 68 - 12
examples/process_pipeline/script/procedure-dsl/run_procedure_dsl.py

@@ -13,10 +13,15 @@ run_procedure_dsl.py — 拿 procedure-dsl/spec.md 当指令本, 让 Claude Agen
 - 单 post 输入, 多终端 = 多并行 (用户自己开).
 
 用法 (本脚本位于 procedure-dsl/, SDK cwd 默认设到 procedure-dsl/):
-    # source 是 input/case-N-raw.json, 脚本自动从 image_url_list 拉图作多模态
-    python run_procedure_dsl.py input/case-2-raw.json --case-id 2
-    python run_procedure_dsl.py input/case-2-raw.json --case-id 2 \\
+    # source 是 input/case-N-raw.json, 脚本自动从 image_url_list 拉图作多模态.
+    # --out-dir 必填: 是 outputs/ 下的目录名, 产物全落这里; case_id 自动从它的 basename 推.
+    python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2
+    python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2 \\
         --extra-image /path/to/local-ref.png --model claude-sonnet-4-6
+    # 跑实验版 spec-test/ 对比 (产物落 outputs/case-2_test/):
+    python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2_test --version test
+    # 中断后恢复 (产物保留, agent 重读磁盘接着跑):
+    python run_procedure_dsl.py input/case-2-raw.json --out-dir case-2 --resume
 
 source 文件 schema (procedure-dsl/input/*.json 约定):
     {
@@ -260,7 +265,13 @@ def _build_initial_blocks(
    - Phase 3 lint + 渲染 → 调 render-case.py + lint-case.py (不写 case_data.json, renderer 内存组装)
    - Phase 3 .md → Write `{case_dir}/case-{case_id}-<slug>.md`
 
-**已读的 spec 模块不要重读** — context 累积, 重读浪费 5-15% budget. 例外: resume 时用户可能改过中间产物, 那些必须重读 (见 resume prompt).
+## ❌ 重复读取禁令 (CRITICAL: ZERO REPEATED READS)
+
+你拥有完美的长期记忆(由于 Context 累积,你读取过的所有文件内容会永远保留在你的 Context 中)。
+请**绝对不要**重复读取任何文件!任何重复的 `Read` 动作都是对 Token 和回合数 Budget 的极大浪费。
+- **禁忌 1**:不要因为看到 spec 文档中写了 `详见 [tools.md §2]` 就去重新 `Read(file_path="{spec_tools}")`。你已经在 Turn 3 读过它了,直接检索你的记忆!
+- **禁忌 2**:不要因为进入了 Phase 2B,就去重新 `Read` 任何 Phase 2 的 spec 文件(如 `phase2-normalize.md`)。你已经在 Phase 2A 开始时读过它了,它就在你的记忆中,直接使用它!
+- 在发出任何 `Read` 指令前,必须自我核对:“我之前读过这个文件吗?”。如果读过,绝对不要再次 Read!
 
 ## 输入
 
@@ -356,7 +367,7 @@ def _fmt_tool_input(name: str, inp: Any) -> str:
         return f"pattern=`{inp.get('pattern', '')}` path=`{inp.get('path', '')}`"
     if name == "Glob":
         return f"pattern=`{inp.get('pattern', '')}`"
-    if name == "Task":
+    if name in ("Task", "Agent"):
         return f"`{inp.get('description', '')}` [subagent={inp.get('subagent_type', '?')}]"
     preview = str(inp)
     return f"`{preview[:200] + '...' if len(preview) > 200 else preview}`"
@@ -452,6 +463,7 @@ async def run(args: argparse.Namespace) -> None:
         RateLimitEvent,
         ResultMessage,
         TextBlock,
+        AgentDefinition,
     )
 
     workdir = Path(args.workdir or DSL_ROOT).resolve()
@@ -497,11 +509,10 @@ async def run(args: argparse.Namespace) -> None:
         blocks = [{"type": "text", "text": (
             f"上次中断了, 接续做 case-{args.case_id} 的提取流程.\n\n"
             f"先 ls {rel_out}/ 看当前已落盘哪些产物;\n"
-            f"用户可能在中断期间编辑过任何中间产物 (understanding.md / steps.json / "
-            f"steps_linked.json / steps_normalized.json / case-*.md / case-*.html) "
+            f"用户可能在中断期间编辑过任何中间产物 (understanding.md / workflow.json) "
             f"或改过 spec/ 内任何文件 — 务必 Read 这些**当前磁盘版本**, "
             f"不要凭之前记忆继续. 如发现明显人工修订痕迹, 沿用用户改过的版本.\n\n"
-            f"然后判断还差哪些阶段没做, 接着做完."
+            f"⚠️【重要禁令与强制要求】:如果流程进行到 Phase 2(归一化与分类匹配),主 Agent **绝对禁止**手动调用 taxonomy-lookup.py 查询或手动决策!你必须强制阅读最新的 `spec/extraction/phase2-normalize.md` 规范,通过运行 `prepare-subtask.py` 生成物理任务切片,然后调用 `Agent`(或 `Task`)工具将任务分别分发给预定义好的 `phase-2a-normalizer` 和 `phase-2b-matcher` 子 Agent 并行协作执行!"
         )}]
     else:
         blocks = _build_initial_blocks(source_for_agent, args.case_id, args.out_dir, images, workdir, args.spec_name)
@@ -537,11 +548,55 @@ async def run(args: argparse.Namespace) -> None:
             stderr_buf.append(line)
             print(f"[stderr] {line}", flush=True)
 
+    agents = {
+        "phase-2a-normalizer": AgentDefinition(
+            description="Expert in Phase 2A (effect/action/type normalization). Use this agent to read task_2a.json, normalize action/effect/type against spec trees, manage procedure-level type_registry, and generate outputs/case-N/_scratch/patch_2a.json.",
+            prompt="""You are a dedicated Phase 2A normalization sub-agent.
+Your goal is to process the inputs and outputs of a workflow for effect, action, and type normalization:
+1. Read the outputs/case-N/_scratch/task_2a.json file to get the steps and IO variables.
+2. Normalize every step's `effect` and `action` against the taxonomy specs in `spec/taxonomy/effect.json` and `spec/taxonomy/action.json`.
+3. Normalize every IO variable's `type` against `spec/taxonomy/type.json`. If a custom type is used, register it in the procedure's `type_registry` with extends and description.
+4. Output a standard `patch_2a.json` JSON file under outputs/case-N/_scratch/.
+IMPORTANT: The format of `patch_2a.json` MUST be a flat JSON array of objects, where each object has a "path" and a "value" key (exactly conforming to the `wf-patch.py` tool contract).
+Example format:
+[
+  {"path": "p1.s1.effect", "value": "预处理"},
+  {"path": "p1.s1.action", "value": "提取/化学提取/反推"},
+  {"path": "p1.s1.inputs[0].type", "value": "工具选型标准"},
+  {"path": "p1.type_registry.工具配置.extends", "value": "评语"},
+  {"path": "p1.type_registry.工具配置.desc", "value": "工具选型依据..."}
+]
+Do not output raw dictionary structure or any other nesting. Do not touch or modify other files.""",
+            tools=["Read", "Grep", "Glob", "Write", "Edit", "Bash"],
+            model="sonnet",
+        ),
+        "phase-2b-matcher": AgentDefinition(
+            description="Expert in Phase 2B (substance/form taxonomy matching). Use this agent to read task_2b.json, invoke taxonomy-lookup.py to query substance and form paths for each variable, and generate outputs/case-N/_scratch/patch_2b.json.",
+            prompt="""You are a dedicated Phase 2B taxonomy matching sub-agent.
+Your goal is to query and match the substance and form for each workflow IO variable:
+1. Read the outputs/case-N/_scratch/task_2b.json file to get the variables to match.
+2. For each variable, run `python spec/tools/taxonomy-lookup.py --dim 实质 --match "..."` and `--dim 形式 --match "..."` to search for the most precise taxonomy paths matching the variable's value, name, and related_images.
+3. Output a standard `patch_2b.json` JSON file under outputs/case-N/_scratch/.
+IMPORTANT: The format of `patch_2b.json` MUST be a flat JSON array of objects, where each object has a "path" and a "value" key (exactly conforming to the `wf-patch.py` tool contract). Substance and form values can be single string paths, multiple paths separated by ' + ', or JSON arrays of strings for multi-path matching.
+Example format:
+[
+  {"path": "p1.s1.inputs[0].substance", "value": "/理念/知识/思想/概念范畴/性质属性/功能效用"},
+  {"path": "p1.s1.inputs[0].form", "value": "/呈现/视觉/视觉制作/构图编排/版面设计/版面结构"},
+  {"path": "p1.s2.inputs[0].substance", "value": ["/理念/知识/商业/前沿技术/AI智能/AI应用", "/理念/知识/思想"]}
+]
+Do not output raw dictionary structure or any other nesting. Do not touch or modify other files.""",
+            tools=["Read", "Grep", "Glob", "Write", "Edit", "Bash"],
+            model="sonnet",
+        )
+    }
+
     options = ClaudeAgentOptions(
         model=args.model,
         cwd=str(workdir),
         resume=resume_sid,
-        allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
+        # ⚠️ 支持旧版 "Task" 与新版 "Agent" 标识符以确保在所有 Claude Code / SDK 版本中均可激活子 agent
+        allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Task", "Agent"],
+        agents=agents,
         max_turns=args.max_turns,
         permission_mode="bypassPermissions",
         setting_sources=[],
@@ -612,9 +667,10 @@ async def run(args: argparse.Namespace) -> None:
                 elif isinstance(msg, RateLimitEvent):
                     info = getattr(msg, "rate_limit_info", None)
                     info_status = getattr(info, "status", None) if info else None
-                    if info_status and info_status != "allowed":
-                        print(f"⚠️  [rate_limit] {info_status!r} — Max 订阅 5h 窗口可能已耗尽, "
-                              f"`claude /status` 看余量", file=sys.stderr)
+                    if info_status == "allowed_warning":
+                        print(f"⚠️  [rate_limit_warning] Max 订阅 5h 窗口余额较少, `claude /status` 看余量", file=sys.stderr)
+                    elif info_status and info_status not in ("allowed", "allowed_warning"):
+                        print(f"❌ [rate_limit_blocked] {info_status!r} — Max 订阅 5h 窗口已耗尽, `claude /status` 看余量", file=sys.stderr)
                         sys.exit(3)
                 else:
                     name = type(msg).__name__

+ 15 - 8
examples/process_pipeline/script/procedure-dsl/spec/README.md

@@ -63,15 +63,15 @@ DSL 的**概念模型 + 字段语义速览** (Phase 1 一次性加载). 不含
 
 | 文件 | 维度 | 规模 | Agent 加载方式 |
 |---|---|---:|---|
-| [taxonomy/README.md](taxonomy/README.md) | — | — | Phase 2 起手读一遍 |
-| [effect.json](taxonomy/effect.json) | 作用 | 9 叶子 (制作 → 准备/加工/收尾阶段) | **整树进 LLM context** (Phase 2 Read) |
+| [taxonomy/README.md](taxonomy/README.md) | — | — | **仅子 Agent 读取**(主 Agent 绝对禁止加载) |
+| [effect.json](taxonomy/effect.json) | 作用 | 9 叶子 | **仅 `phase-2a-normalizer` 自动加载并读取**(主 Agent 绝不加载,以维持极简 Context) |
 | [action.json](taxonomy/action.json) | 动作 | 30 叶子 + 5 control | 同上 |
-| [type.json](taxonomy/type.json) | 类型 | 50 叶子 (程序控制/数据复用/内容/知识 四大类) | 同上. 每节点含 `分类说明` (边界/同义词); case-specific 类型走 `workflow.json` 的 `procedures[i].type_registry` (extends 一个叶子, Agent 写) |
-| [type_suggestions.md](taxonomy/type_suggestions.md) | 类型升级提案 | append-only | **Agent 走 §2A 候选→extends 桥接时 append**, spec 维护者审阅决定升级到 type.json |
-| `taxonomy/分类库导出_实质_*.json` | 实质 | 911 路径 (1.1 MB) | **走 `spec/tools/taxonomy-lookup.py` query** — 整文件不进 context |
-| `taxonomy/分类库导出_形式_*.json` | 形式 | 565 路径 (764 KB) | 同上 |
+| [type.json](taxonomy/type.json) | 类型 | 50 叶子 | 同上。每节点含 `分类说明`;case-specific 类型自动写入 `workflow.json` 的 `type_registry` |
+| [type_suggestions.md](taxonomy/type_suggestions.md) | 类型升级提案 | append-only | 阶段三跑 `lint-case.py` 时**由工具自动同步 append**,Agent 无需手工编辑 |
+| `taxonomy/分类库导出_实质_*.json` | 实质 | 911 路径 | **仅由 `phase-2b-matcher` 内部通过脚本查询**(主 Agent 绝不读取和查询) |
+| `taxonomy/分类库导出_形式_*.json` | 形式 | 565 路径 | 同上 |
 
-> ⚠ **实质/形式 JSON 不要直接 Read** — 大词表 bundled 在这只是为让 `taxonomy-lookup.py` 找得到. 整文件进 context 会吃 30k+ tokens. Phase 2 2B 子 Agent 走 tool funnel API (`--list-l2 / --subtree / --match / --validate`).
+> ⚠ **主 Agent 绝对隔离原则**:上述所有分类树文件和查询脚本均**只对子 Agent 开放**。主 Agent 在整个流程中**绝对不要直接 Read 它们,也绝对不要直接调用 `taxonomy-lookup.py`**!主 Agent 唯一的职责是运行物理切片脚本 `prepare-subtask.py` 并分发 `Agent` 任务,以此彻底杜绝主 Agent 发生 Context 膨胀或违背隔离架构手动通关。
 
 ### Templates · 渲染模板 ([templates/](templates/))
 
@@ -133,7 +133,14 @@ Agent 跑提取时**通过 Bash 调用**的脚本. **Agent 不需要 Read 这些
 
 读本 README **一遍**, 之后**永远不要再 Read 本文件**. 你需要 phase 细节时直接读 phase 对应的 spec 模块, 不要回来翻 README.
 
-也请把 [`tools.md`](tools.md) 读一遍 — 你后续要调的外部脚本接口都在那, 之后**不要重读**.
+也请把 [`tools.md`](tools.md) 读一遍 — 你后续要调的外部脚本接口都在那, 之后**不要重读**。
+
+### 🛑 绝对禁止重复读取 (ZERO REPEATED READS RULE)
+由于 Context 是持续累积的,**你读取过的所有文件(README.md, tools.md, phase2-normalize.md 等)内容会永远保留在你的 Context 记忆中**。
+请**绝对不要**再次调用 `Read` 读取它们!重复读取会导致巨大的 Token 浪费与回合预算超限。
+- **严禁**:因为看到 spec 里写了 `详见 [tools.md §2]` 就去重复 `Read(file_path="spec/tools.md")`。
+- **严禁**:因为进入了 Phase 2B 就去重复 `Read(file_path="spec/extraction/phase2-normalize.md")`。
+- 检索你的前置 Turn 历史,直接从记忆中查找对应规格!
 
 ### Phase 1 · 骨架提取 (新增加载)
 

+ 2 - 2
examples/process_pipeline/script/procedure-dsl/spec/extraction/fields.md

@@ -117,7 +117,7 @@
 | **形式** (form) | 内容怎么呈现 (呈现 / 架构 层面) | 外部词表 `分类库导出_形式_*.json` | 同上 | 同上 |
 | **类型** (type) | 领域语义类型 (按功能角色分类) | §A.3 字典树叶子 或 case-specific extends | 命中叶子优先; 否则用 case extends 桥接 | 不在字典树且无 extends 桥接的自造名 |
 | **变量名** (name) | 该步内的数据流标识 | case-specific 命名, 按内容含义起 | 同一概念在不同 step 必须用同一名 (lint: 命名归一) | 同名变量在不同 step 类型不一致 |
-| **值** (value) | **数据本身的内容** | 原文 / 上下文中的实际数据 | 文本数据写完整 prose; 非文本数据用 `<整段描述>` 包起 | 含 meta ("同 sX:" / "(从 X 切出)" / "供后续复用" / "保连续" / "人工挑 1") — 这些归 anchor / decorator / memo / config |
+| **值** (value) | **数据本身的内容** | 原文 / 上下文中的实际数据 | 文本数据写完整 prose; **关于内容本身的提示词**(如具体的画面描述、场景风格、角色特征、服装细节等)作为数据本身内容,**必须**在此回填;非文本数据用 `<整段描述>` 包起 | 含 meta ("同 sX:" / "(从 X 切出)" / "供后续复用" / "保连续" / "人工挑 1") — 这些归 anchor / decorator / memo / config; **与内容本身不相关的工具指令/工作流命令**(该归入指令列 ❌) |
 | **来源** (anchor, 输入) / **去处** (anchor, 输出) | 输入侧: 上游引用 + 容器索引; 输出侧: 下游去向 + 容器索引 | 上游 step output / declare / 字面量 / 下游 step | 输入: `← 工序输入` / `← sN.varname` / `← s6 (链, 上一张)` / `← 分镜序列[i]`; 输出: `→ s7` / `→ 视频片段列表.追加` / `→ 返回 短剧` | 描述内容 (那是 value); 描述用法 |
 
 > ⚠ **value 必须逐字回填数据本身, 禁止引用占位**: 即使某输入就是上游输出**原样透传** (anchor 已是 `← sN.x`), value 也要填该数据**全文**, 不能写「(同 sN 输出)」「见 sN」「← sN」这类引用 —— 那是 anchor 的活, value 写了占位等于丢内容 (directive 同理: 必须是实际喂给工具的 prompt 原文). **不想手抄 N 遍**: 只在源头 (sN 的 output) 填一次真值, 透传位置设好 anchor 后跑 `wf-patch.py --resolve-passthrough`, 工具顺 anchor 把源 value 逐字抄到所有透传 value/directive (见 [tools.md §2](../tools.md)). lint 会扫这类占位报警.
@@ -128,7 +128,7 @@
 |---|---|---|---|---|
 | **外部工具** (via) | L1 canonical 工具名 | 工序作者指定 | `manus` / `nano_banana_pro` / `<llm-agent>` / `human`; 原文未指名用 `(generic 描述)` 占位 (如 `(AI 生图工具)`); 控制流块用 `-` | 描述性短语 (该归 directive) |
 | **动作** (action) | 工具的内容动作 kind | §A.2 字典树路径 | `提取/化学提取/反推`, `生成/元素生成`, `修改/增/添加`; 控制类已分流到 `逻辑控制`, 这里不放 | 自造动词 |
-| **指令** (directive) | **字面 prompt 文本** — 真正喂给工具的 prompt 字符串 | 原文中的 prompt 引文; 没引文则按工艺反推一段合理 prompt | 祈使句, 像直接复制粘贴的 prompt. 引号包裹原文 quote: `"反推这个视频的提示词, 拆解出 9 个维度: 审美/运镜/拍摄/..."` | "上传文件, 让 manus 反推" (这是步骤说明 ❌); "智能体外层 prompt: 按..." (描述工艺逻辑 ❌). **人工 / 控制流 step 无 prompt, directive 应空** |
+| **指令** (directive) | **字面 prompt 文本** — 真正喂给工具的 prompt 字符串 | 原文中的 prompt 引文; 没引文则按工艺反推一段合理 prompt | 祈使句, 像直接复制粘贴的 prompt. 引号包裹原文 quote: `"反推这个视频的提示词, 拆解出 9 个维度: 审美/运镜/拍摄/..."`. **关于工具指令、流程控制、或者和生成内容本身没有直接关系的指示**(如 “反推提示词”、“请用 SD 进行重绘”、“比例 2:3”、“帮我生成图片:”等命令描述)作为实现手段,**必须**归入此列而非输入 value 列 | "上传文件, 让 manus 反推" (这是步骤说明 ❌); "智能体外层 prompt: 按..." (描述工艺逻辑 ❌); **纯画面内容描述词**(应归入输入数据流的值 value ❌). **人工 / 控制流 step 无 prompt, directive 应空** |
 | **配置** (config) | **工具运行参数** (单次调用可设的数值/枚举) | 工具 UI / API 参数 | 例: `{ 2K · 1:1 · 4 张 · 模式=智能参考 }` / `aspect=16:9, dur=12s` / `UI audio=开` | "免费配额: 免登录视频 4 / 图片 10" (账户层事实, 非运行参数 ❌, 该归 memo 或工序级 declarations); 描述性句子 |
 | **运行** (decorator) | **caller-side 调用修饰** — `@` 装饰器 | 工序作者添加 | `@采样(n=4, pick=人工)` / `@重试(max=3)` / `@缓存` / `@限流` | 工具本身参数 (那归 config); 描述工艺 |
 | **备注** (memo) | **其他列没承载的实现方式信息** — 工艺级 wisdom | 工艺经验总结 | (1) 经验性招法 trick: "动作不连贯时优先用上一张作链参考"; (2) 替代 variant 说明: "本 case 走方法 3 手写; 替代: 方法 1 让 manus 拆视频"; (3) 工具选型理由: "工具选型唯一硬约束: 必须免费"; (4) 适用条件 / 边界 / 已知坑 | "本步独立, 不进入主流程" (analytical commentary ❌); "原文: '...'" (溯源引文 ❌); "拆 3 维度的本质是 prompt 块复用" (analytical, 非实现方式 ❌); 工具账户层运营事实 (那是工序级别, 非本 step 实现) |

+ 45 - 6
examples/process_pipeline/script/procedure-dsl/spec/extraction/phase2-normalize.md

@@ -1,14 +1,51 @@
 #### 阶段二 · 归一化与标注 (主 Agent fan-out 2 个子 Agent)
 
-主 Agent 把 `workflow.json` 发给两个子 Agent 并行处理, 都 **in-place 加字段** (不要 Write 重写整个文件). 收集后合并的状态仍是同一份 workflow.json (子 Agent 改的是不同字段, 不冲突).
+**工作模式 (并行子 Agent 架构)**:
+为了彻底隔离重负载上下文(含 Base64 图片和 Phase 1 漫长推理历史)并最大化提取效率,**主 Agent 必须使用 `Task` 工具将 Phase 2 任务分流给 2 个并行的子 Agent**。
+* 主 Agent **绝对不要**自己去跑这几十次 `taxonomy-lookup` 查询和 `wf-patch` 校验。
+* **物理切片与图片分流**:主 Agent 在启动 Phase 2 时,必须先运行物理切片脚本:
+  ```bash
+  python spec/tools/prepare-subtask.py --workflow outputs/case-N/workflow.json --source input/case-N.json --out-dir outputs/case-N/_scratch
+  ```
+  该脚本会自动在 `outputs/case-N/_scratch` 目录下生成最小化的子任务定义文件 `task_2a.json` 与 `task_2b.json`。
+* **图片及数据无缝对齐**:为了让子 Agent 具备完美的上下文感知能力,`task_2a.json` 和 `task_2b.json` 不仅包含了精简的 IO 变量与步骤信息,还在根节点内置了当前 Case 的完整图集 `image_url_list`,并为每一个具体的 IO 变量对象级关联了其对应的 `related_images` 引用数组(如识别 `图05` 等关联图片)。
+* 子 Agent 采用极简 Context(2k tokens,不载入大图),只读取当前 `{case_dir}/_scratch/task_2a.json` 或 `task_2b.json`,跑完查询后只返回标准的 `patch_2a.json` 或 `patch_2b.json` 补丁内容。
+* 主 Agent 只负责运行切片脚本、唤醒子 Agent 并行作业,最后收集子 Agent 返回的补丁文件(或 patch 文本段),通过 `wf-patch.py --patch` 一体化应用落盘。
+
+**子 Agent 召唤机制与指令示范 (主 Agent 必看)**:
+在 `run_procedure_dsl.py` 环境中,我们已通过编程式(`agents` 字段)在 Claude SDK 中**预先注册**了两个常驻子 Agent 角色:
+1. `phase-2a-normalizer` (作用/动作/类型归一化专家)
+2. `phase-2b-matcher` (实质/形式词表精确匹配专家)
+
+主 Agent 在运行中可通过以下两种极其优雅的方式激活并分流任务:
+
+### 🟢 方式一:显式指定调用(推荐,指向性极强)
+直接在 prompt 里命令子 Agent 工作,或在 Tool call 中指定其名称调用:
+```json
+// 1. P2A 子 Agent 显式召唤示例:
+Agent(
+  subagent_type="Explore",
+  description="召唤 phase-2a-normalizer 专家处理 2A 任务",
+  prompt="请 phase-2a-normalizer 子 agent 立即读取 outputs/case-N/_scratch/task_2a.json 任务文件,结合 spec/ 里的 effect.json、action.json、type.json,为各步骤和 IO 变量完成作用/动作/类型归一化。完成后请在 outputs/case-N/_scratch/ 下写入标准的 patch_2a.json 并向我汇报具体修改项。"
+)
+
+// 2. P2B 子 Agent 显式召唤示例:
+Agent(
+  subagent_type="Explore",
+  description="召唤 phase-2b-matcher 专家处理 2B 任务",
+  prompt="请 phase-2b-matcher 子 agent 立即读取 outputs/case-N/_scratch/task_2b.json 任务文件,使用 spec/tools/taxonomy-lookup.py 工具查询词表,查出各 IO 变量最精准的 substance/form 路径。完成后请在 outputs/case-N/_scratch/ 下写入标准的 patch_2b.json 并向我汇报具体修改项。"
+)
+```
 
-**工作模式 (重要)**: Phase 1 已经 Write 了 workflow.json 骨架, Phase 2 子 Agent **Read workflow.json + 给已有 step/IO 逐字段填值**, 不写新文件。每个值都是**逐 step 的语义判断**(这个 step 的 effect 是哪片叶子、这个 IO 的 type 是什么)—— **不要写 normalize 脚本去机械批改**, 那等于把判断退化成代码里的映射规则。落字段的两种方式:
-- **批量** (一个子任务几十个 effect/action/type/substance/form): 用 `wf-patch.py` —— 把决策写成 `--patch _scratch/2a.json` 清单 (`[{"path":"p1.s2.effect","value":"主体生成"}, ...]`) 一次应用。工具**写入即校验** (effect 必须是叶子、type 必须叶子或已注册、substance/form 走词表校验), **非法整批不写**, 你不碰 JSON 序列化。详见 [tools.md §2](../tools.md)。
-- **零星单处**: 直接 Edit。
+### 🔵 方式二:自动触发(编程式 description 自动匹配)
+由于 SDK 在后台配置了 `AgentDefinition`,主 Agent 在发起一般的 prompt 对话时如果提到相关职责,Claude SDK 也会基于 descriptions 自动路由调用:
+```
+Use the phase-2b-matcher agent to run taxonomy lookup for task_2b.json and generate patch_2b.json
+```
 
 这样:
 - 单一真理源, 没有 phase 间复制 → 零冗余
-- 你只出 path=value 决策, 工具保证不写坏 JSON + 字段合法 → 出错率低 (告别"脚本拼坏 JSON 再 fix" 的螺旋)
+- 主 Agent 只出并行分发与合并决策,极速降本 90%+,速度提升 10x
 - Resume 中断时看 workflow.json 里某 step 是否有 effect 字段就知道 Phase 2A 做没做
 
 ##### 2A 子 Agent — 作用 / 动作 / 类型 归一化
@@ -76,11 +113,13 @@ spec/tools/taxonomy-lookup.py --dim {实质|形式} --validate <path>          #
 3. 上面拿到 top 候选后, 用 `--subtree <候选>` 列叶子细节, 选最贴的
 4. `--validate <chosen_leaf>` 确认前再写回; 完全无法匹配 → `unmatched`
 5. 抽象容器/纯工具参数 → null (不标)
+   - ⚠ **警惕过度标注 null**:诸如 `产品需求`、`结构化提示词`、`分层提示词` 等文本,虽然物理形式上是“文本”,但它们在**实质(Substance)上承载了具体的业务内容**(例如:提示词若描述冲锋衣,实质即为 `/理念/知识/商业/产品服务/产品特征`;若描述登山场景,则为 `/理念/知识/商业/产品服务/使用场景`)。**它们决非纯工具参数**,必须为其匹配对应的实质路径。
+   - **仅有完全不含具体业务实体、纯粹的技术参数**(如逻辑判断布尔值、循环索引 `i`、纯控制流指令等)才允许设为 `null`。
 
 > ⚠ **避坑**: 不要把整段 value 描述塞进 `--match` (e.g. `--match "苏晚 25 岁年轻女性, 卧室床上, 湿发素颜"`),
 > 标点会被当 token 一部分干扰拆词. 提炼 2-5 个干净的描述性词组就够.
 
-**输出**: **写回 workflow.json** (几十个 IO 批量用 `wf-patch.py --patch`, 见开头工作模式; substance/form 会自动走 taxonomy-lookup 校验, 设 null 传 `__null__`), 给每个 IO 加 `substance: /xxx/yyy` + `form: /xxx/yyy` (或 null). 不写新文件, 不写生成脚本.
+**输出**: **写回 workflow.json** (几十个 IO 批量用 `wf-patch.py --patch`, 见开头工作模式; substance/form 会自动走 taxonomy-lookup 校验, 设 null 传 `__null__`), 给每个 IO 加 `substance: /xxx/yyy` 或数组 `["/xxx/yyy", "/zzz"]` + `form: /xxx/yyy` 或数组 (或 null). 不写新文件, 不写生成脚本。对于复杂内容,推荐在 CLI 传入时用 `+` 连接多条路径 (如 `/表象/视觉/人物 + /表象/视觉/空间`),`wf-patch.py` 会自动校验并将其作为 JSON 数组保存至 `workflow.json` 中。
 
 ---
 

+ 6 - 4
examples/process_pipeline/script/procedure-dsl/spec/format/case-data.schema.json

@@ -266,12 +266,14 @@
       "additionalProperties": false,
       "properties": {
         "substance": {
-          "type": ["string", "null"],
-          "description": "What the content IS (理念 vs 表象 layer). Path from `分类库导出_实质_*.json`. null for abstract containers / pure tool params."
+          "type": ["string", "array", "null"],
+          "items": { "type": "string" },
+          "description": "What the content IS (理念 vs 表象 layer). Path from `分类库导出_实质_*.json`. Can be a single path string, an array of path strings, or null for abstract containers / pure tool params."
         },
         "form": {
-          "type": ["string", "null"],
-          "description": "How the content is PRESENTED (呈现 vs 架构 layer). Path from `分类库导出_形式_*.json`. null when not applicable."
+          "type": ["string", "array", "null"],
+          "items": { "type": "string" },
+          "description": "How the content is PRESENTED (呈现 vs 架构 layer). Path from `分类库导出_形式_*.json`. Can be a single path string, an array of path strings, or null when not applicable."
         },
         "type": {
           "type": "string",

+ 1 - 1
examples/process_pipeline/script/procedure-dsl/spec/syntax.md

@@ -36,7 +36,7 @@ DSL 表达分 5 层, 职责不同, 演化速度不同。
 - **实质** = 内容**是什么**(人物 / 场景 / 道具 / 观念 / 事件 …)
 - **形式** = 内容**怎么呈现 / 组织**(光照 / 色调 / 构图 / 运镜 / 叙事 / 修辞 …)
 
-取值来自**外部词表**(`分类库导出_{实质,形式}_*.json`,走 `taxonomy-lookup.py` 查,不进 context)。**标注对象是变量的 `value`(具体素材),不是变量名 / 类型** —— 例:变量"主角图"代表的那张具体图(女主特写)才是被标注对象,标 `实质:/表象/视觉/人物 + 形式:/呈现/视觉/特写`。
+取值来自**外部词表**(`分类库导出_{实质,形式}_*.json`,走 `taxonomy-lookup.py` 查,不进 context)。**标注对象是变量的 `value`(具体素材),不是变量名 / 类型** —— 例:变量"主角图"代表的那张具体图(女主特写)才是被标注对象,标 实质:/表象/视觉/人物 + 形式:/呈现/视觉/特写 (对复杂素材支持多路径标注,以 `+` 连接,在 `workflow.json` 中保存为 JSON 数组,如 `["/表象/视觉/人物", "/表象/视觉/空间"]`)
 
 ### 2.2 PromptBlock(prompt 的结构化思路)
 

+ 56 - 0
examples/process_pipeline/script/procedure-dsl/spec/taxonomy/type_suggestions.md

@@ -62,3 +62,59 @@
 - `试穿场景图集`: 多场景试穿图的集合(露营/生活/徒步三场景)  (来自 case-new_dir, extends `样图`)
 - `产品创意图`: 智能纺织品科学可视化风格创意图,体现科技感与可持续发展理念  (来自 case-new_dir, extends `成品图`)
 - `功能示意图`: 产品功能半剖视3D示意图,颜色编码+箭头可视化功能流向  (来自 case-new_dir, extends `成品图`)
+
+- `需求简述`: 冲锋衣功能示意图的设计需求:防水透湿三层结构+3D剖面图风格  (来自 case-my_test, extends `描述`)
+- `场景描述列表`: 多个使用场景的名称/描述列表,如['露营','生活','徒步']  (来自 case-my_test, extends `大纲`)
+- `场景描述`: 单个使用场景的文字描述,用于引导单次试穿图生成  (来自 case-my_test, extends `描述`)
+- `试穿场景图`: AI生成的模特穿着产品的单场景候选图  (来自 case-my_test, extends `样图`)
+- `海报文案`: 海报用品牌文案(tagline + 产品功能描述文字,由品牌方提供)  (来自 case-my_test, extends `正文`)
+- `产品创意图`: 智能纺织品SEM/光纤光学风格科技展示创意图,以伪彩色纤维可视化为核心  (来自 case-my_test, extends `成品图`)
+- `功能示意图`: 冲锋衣防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-my_test, extends `成品图`)
+
+- `需求简述`: 冲锋衣功能示意图的设计需求:防水透湿三层结构+3D剖面图风格  (来自 case-?, extends `描述`)
+- `场景描述列表`: 多个使用场景的名称/描述列表,如['露营','生活','徒步']  (来自 case-?, extends `大纲`)
+- `场景描述`: 单个使用场景的文字描述,用于引导单次试穿图生成  (来自 case-?, extends `描述`)
+- `试穿场景图`: AI生成的模特穿着产品的单场景候选图  (来自 case-?, extends `样图`)
+- `海报文案`: 海报用品牌文案(tagline + 产品功能描述文字,由品牌方提供)  (来自 case-?, extends `正文`)
+- `产品创意图`: 智能纺织品SEM/光纤光学风格科技展示创意图,以伪彩色纤维可视化为核心  (来自 case-?, extends `成品图`)
+- `功能示意图`: 冲锋衣防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-?, extends `成品图`)
+
+- `调色预设`: 在百度网盘AI修图中保存的调色参数套装(色温/饱和度/色调等),可跨次调用复刻同款色调风格  (来自 case-?, extends `滤镜`)
+
+- `账户权限`: 使用AI生图功能的前提状态——账户已登录、功能已解锁、算力充足  (来自 case-14, extends `描述`, 出现位置: p1.s1.inputs[0], p2.s1.inputs[0])
+- `小程序界面`: AI生图小程序的UI界面屏幕状态(主界面及各子页面)  (来自 case-14, extends `描述`, 出现位置: p1.s1.outputs[0], p2.s1.outputs[0])
+- `模板库界面`: 模板参考库UI界面(含行业筛选、缩略图网格、应用提示词按钮)  (来自 case-14, extends `描述`, 出现位置: p1.s2.outputs[0])
+- `门店信息`: 用户门店专属信息(店铺名称、活动内容、时间、优惠规则等需填入海报的要素)  (来自 case-14, extends `描述`, 出现位置: p1.s5.inputs[1])
+- `设计意图`: 用户对海报的完整自定义设计意图(尺寸/比例、风格、文案、画面元素、特殊需求)  (来自 case-14, extends `描述`, 出现位置: p2.s2.inputs[0])
+
+- `账户权限`: 使用优客赢文字生图功能的前提:账户已登录、功能已解锁、当前算力充足  (来自 case-case_14, extends `描述`)
+- `小程序界面`: 优客赢小程序文字生成图功能的UI界面状态  (来自 case-case_14, extends `描述`)
+- `模板库界面`: 优客赢模板参考库的UI界面:含行业分类筛选、模板缩略图网格、应用提示词按钮  (来自 case-case_14, extends `描述`)
+- `门店信息`: 用户门店专属信息:店铺名称、活动内容、时间、优惠规则等需填入海报的信息  (来自 case-case_14, extends `描述`)
+- `设计意图`: 用户对海报的完整设计意图:尺寸/比例、整体风格、文案内容、画面元素、特殊需求  (来自 case-case_14, extends `描述`)
+
+- `写真图集`: AI生成的主题人像写真图片集合,多张统一主题写真图构成一套交付物  (来自 case-6, extends `成品图`, 出现位置: p1-p6.s2.outputs[0], s3.inputs[0], s3.outputs[0])
+
+- `写真图集`: AI生成的主题人像写真图片集合,多张统一主题写真图构成一套交付物  (来自 case-case_6, extends `成品图`)
+
+- `写真图集`: 豆包AI批量生成的九张氛围感写真照片集合,同一角色不同姿态动作,2:3竖版比例  (来自 case-case_10, extends `成品图`)
+
+- `背景图`: 冲锋衣宣传海报用极简写实背景图,无人物无产品,哑光光效,渐层构图,供合成叠加使用  (来自 case-case_5, extends `底图`)
+- `场景设定列表`: 要生成试穿图的场景名称列表,如[露营, 城市生活, 徒步]  (来自 case-case_5, extends `大纲`)
+- `场景试穿图列表`: 遍历各场景生成的产品试穿图集合(露营/城市生活/徒步三场景)  (来自 case-case_5, extends `样图`)
+- `场景试穿图`: AI基于产品参考图生成的单场景产品试穿候选图  (来自 case-case_5, extends `样图`)
+- `宣传海报`: 背景图+产品参考图+试穿图+文案合成的最终冲锋衣宣传海报  (来自 case-case_5, extends `合成图`)
+- `创意图`: 智能纺织品SEM/光纤光学风格科学可视化创意图,体现科技感与可持续发展理念  (来自 case-case_5, extends `成品图`)
+- `功能示意图`: 冲锋衣防水透湿功能半剖面3D示意图,颜色编码+箭头可视化功能流向  (来自 case-case_5, extends `成品图`)
+
+- `背景图`: 冲锋衣宣传海报用极简写实背景图,无人物无产品,哑光光效,渐层构图,供合成叠加使用  (来自 case-5, extends `底图`)
+- `场景设定列表`: 要生成试穿图的场景名称列表,如[露营, 城市生活, 徒步]  (来自 case-5, extends `大纲`)
+- `场景试穿图列表`: 遍历各场景生成的产品试穿图集合(露营/城市生活/徒步三场景)  (来自 case-5, extends `样图`)
+- `场景试穿图`: AI基于产品参考图生成的单场景产品试穿候选图  (来自 case-5, extends `样图`)
+- `宣传海报`: 背景图+产品参考图+试穿图+文案合成的最终冲锋衣宣传海报  (来自 case-5, extends `合成图`)
+- `创意图`: 智能纺织品SEM/光纤光学风格科学可视化创意图,体现科技感与可持续发展理念  (来自 case-5, extends `成品图`)
+
+- `工具配置`: 对多个候选工具的评估筛选结论,包含选定工具名称及推荐理由,作为后续生成步骤的工具选型依据  (来自 case-case_13, extends `评语`)
+
+- `工具配置`: 对所选AI工具的名称、核心特点及推荐理由的文字描述,作为后续生成步骤中使用该工具的选型依据  (来自 case-case_13_test, extends `描述`)
+- `海报方案`: AI生成的海报候选设计方案,包含标题文字、图片元素、配色方案、版式排版等完整设计内容,经人工筛选后进入后续编辑阶段  (来自 case-case_13_test, extends `样图`)

+ 224 - 3
examples/process_pipeline/script/procedure-dsl/spec/tools.md

@@ -81,22 +81,28 @@ python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json --resolv
 | `p1.s2.effect` | step 标量 (effect / via / action / feature / control / kind / intent / group) |
 | `p1.s1.inputs[0].anchor` | IO 字段 (anchor / type / substance / form / name / value) |
 | `p1.s2.1.outputs[0].type` | 嵌套步的 IO |
+| `p1.s2.focus` | step 的 focus 数组 (逗号分隔: `focus=via,action,out-type-0`) |
 | `p1.purpose` | procedure 头部 (name / purpose / category / platform / author) |
+| `p1.declarations.inputs[0].desc` | declarations 内任意字段 (通用下钻) |
+| `source.url` | case-level 原帖信息 (platform / author / date / url / title / excerpt) |
 | `p1.type_registry.场景图.extends` | 注册 case-specific 类型 (自动建 type_registry 段) |
 
+**`--unset PATH`** (删字段, 可重复): 删掉某字段, 取代手 Edit 删. e.g. `--unset p1.declarations.inputs[0].inferred` (declarations 不收 inferred, schema 会拒). 字段本就不存在 → 跳过 (幂等, 不报错).
+
 **各字段校验规则**:
 - `effect` → effect.json 叶子 (给全路径会自动归一到叶名)
 - `action` → action.json 叶子 / 叶路径 (给叶名自动展开成全路径)
 - `type` → type.json 叶子 **或** 本 procedure 已注册的 type_registry 名 (没注册会提示先 `--set p.type_registry.X.extends=<叶子>`)
 - `extends` → 必须桥到 type.json 叶子
-- `substance` / `form` → 走 taxonomy-lookup `--validate` 校验 (传 `__null__` 设 JSON null)
+- `substance` / `form` / `url` → substance/form 走 taxonomy-lookup `--validate`; 三者均可传 `__null__` 设 JSON null
 - `anchor` → 必须 `←` (输入引用) 或 `→` (输出去向) 开头
 - `feature` → 受控词 {随机 / 幂等 / 人工 / 本地 / 写外部 / 读外部 / -}
 - `control` → 受控词 {并行 / 遍历 / 分支 / 请求 / 等待 / -}
 - `kind` → {step / block / nested / atom}
+- `focus` → 逗号分隔 → 数组 (空串 → `[]`)
 - 其余 (name / value / intent / via / desc / purpose / ...) → 自由文本, 不校验
 
-**不在职责内** (仍用 Write / Edit): 骨架首次创建 (Phase 1.2 从 template Write); `instruction` (列表套列表, 1-2 行手动 Edit); 删字段 / 删 step / 调结构.
+**仍用 Write / Edit 的只剩** (尽量别碰生 JSON, 这两类才用): 骨架首次创建 (Phase 1.2 从 template Write); `instruction` 列表 (套列表结构, 手动 Edit; 透传 directive 用 `--resolve-passthrough`). 改字段 / 删字段 / 改 source 现在都走 wf-patch, **不要再 Read→Edit 改 workflow.json** (那会反复重读、烧 token).
 
 **退出码**: `0` 全通过并写入 (或 `--dry-run` 通过) / `1` 有校验失败 (整批未写) / `2` CLI 错 / 文件不存在 / JSON 损坏.
 
@@ -197,7 +203,7 @@ python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-
 跑 Agent 的入口脚本. **Agent 不读它** — 它在跑 Agent. 但 Agent 可能想知道**自己被怎么起的**:
 
 - OAuth Max 模式 (走 ~/.claude 凭证, 不计 API 费)
-- `allowed_tools = ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]`
+- `allowed_tools = ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Task"]`  # 🟢 包含 Task 召唤子 Agent 工具
 - `permission_mode = "bypassPermissions"` (Agent 全自动, 不停下来问)
 - `cwd = procedure-dsl/` (Agent 的工作目录)
 - `--resume` 支持 (中断后续跑)
@@ -230,3 +236,218 @@ python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-
 - **走 tool 比走 Read 更经济**: 配 `spec/tools/taxonomy-lookup.py` 的 funnel API (`--list-l2 / --subtree / --match`), 一次 query 几百 tokens, 比整树 Read 省 50-100x
 
 Agent 阶段二 2B 完全通过 tool 查, 不要 Read 词表 JSON 本身.
+
+---
+
+## 9. `Agent` (或 `Task`) — Claude Agent SDK 子 Agent 召唤工具
+
+**用途**: 用于在 Phase 2 (归一化与标注) 中将庞大重负载的主 Agent 上下文(含 14+ Base64 图片及 40k+ 规格说明书)与细碎耗费 Token 的高频分类查询进行**上下文隔离与并发分流**。
+
+**工作原理**:
+- 允许主 Agent 创建并行的、无图片和规格历史包袱的轻量化子 Agent。
+- **安全 IO**: 工具自己 load → 改 → dump(ensure_ascii=False), 你永远不手写 JSON, 不会写坏文件
+- **写入即校验 (fail-fast)**: 每条赋值立刻对照字典树 / 本工序 type_registry / anchor 格式校验; **任何一条非法 → 报具体哪条错, 整批不写** (不产出悄悄错的文件). 校验规则跟 lint 同源 (同款叶子派生), **patch 通过 = lint 必过**
+
+**用法**:
+```bash
+# 单条 / 多条 --set (path=value, 只在第一个 = 处切; value 可含 = 和空格, 整体加引号)
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json \
+    --set 'p1.s1.inputs[0].anchor=← s0.主角图' \
+    --set 'p1.s2.effect=主体生成' \
+    --set 'p1.s2.action=生成/图像生成/文生图'
+
+# 批量: 几十处 anchor / 字段一次过 — 写一份 patch 清单 (你仍逐条显式决策每个值), 一条命令应用
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json --patch _scratch/anchors.json
+#   anchors.json = [{"path":"p1.s1.inputs[0].anchor","value":"← s0.x"}, ...]
+
+# 只校验不写
+python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
+
+# 透传回填: anchor 设好后, 自动把"原样透传"的 value/directive 从源逐字抄过来
+python spec/tools/wf-patch.py --workflow outputs/case-{N}/workflow.json --resolve-passthrough
+```
+
+**`--resolve-passthrough` (重要, 省掉重复抄写)**: spec 要求 `value` **逐字回填数据本身**, 不能写「(同 sN 输出)」「见 sN」这类引用占位 (那是 anchor 的活, lint 会报). 但一个 prompt 被下游 N 步原样复用时, 手抄 N 遍既烦又是 agent 写占位偷懒的根源. 这个模式让你**只在源头 (s1 的 output) 填一次真值**, 其余透传位置只管设 anchor (`← s1.xxx`), 然后工具顺 anchor 把源 value **逐字抄到**每个 value 空/占位的 IO + 每个「同 sN.x」的 directive, **迭代到不动点** (链式透传也覆盖). 源找不到的会 `⚠` 报出来. 可单独跑, 也可跟在 `--set/--patch` 后 (先赋值再回填).
+
+**路径语法** (proc / step 按 **id** 寻址, 不是下标; 只有真列表才用 `[i]`; 嵌套步 id 带点如 `s2.1` 也支持):
+
+| 路径 | 设的字段 |
+|---|---|
+| `p1.s2.effect` | step 标量 (effect / via / action / feature / control / kind / intent / group) |
+| `p1.s1.inputs[0].anchor` | IO 字段 (anchor / type / substance / form / name / value) |
+| `p1.s2.1.outputs[0].type` | 嵌套步的 IO |
+| `p1.s2.focus` | step 的 focus 数组 (逗号分隔: `focus=via,action,out-type-0`) |
+| `p1.purpose` | procedure 头部 (name / purpose / category / platform / author) |
+| `p1.declarations.inputs[0].desc` | declarations 内任意字段 (通用下钻) |
+| `source.url` | case-level 原帖信息 (platform / author / date / url / title / excerpt) |
+| `p1.type_registry.场景图.extends` | 注册 case-specific 类型 (自动建 type_registry 段) |
+
+**`--unset PATH`** (删字段, 可重复): 删掉某字段, 取代手 Edit 删. e.g. `--unset p1.declarations.inputs[0].inferred` (declarations 不收 inferred, schema 会拒). 字段本就不存在 → 跳过 (幂等, 不报错).
+
+**各字段校验规则**:
+- `effect` → effect.json 叶子 (给全路径会自动归一到叶名)
+- `action` → action.json 叶子 / 叶路径 (给叶名自动展开成全路径)
+- `type` → type.json 叶子 **或** 本 procedure 已注册的 type_registry 名 (没注册会提示先 `--set p.type_registry.X.extends=<叶子>`)
+- `extends` → 必须桥到 type.json 叶子
+- `substance` / `form` / `url` → substance/form 走 taxonomy-lookup `--validate`; 三者均可传 `__null__` 设 JSON null
+- `anchor` → 必须 `←` (输入引用) 或 `→` (输出去向) 开头
+- `feature` → 受控词 {随机 / 幂等 / 人工 / 本地 / 写外部 / 读外部 / -}
+- `control` → 受控词 {并行 / 遍历 / 分支 / 请求 / 等待 / -}
+- `kind` → {step / block / nested / atom}
+- `focus` → 逗号分隔 → 数组 (空串 → `[]`)
+- 其余 (name / value / intent / via / desc / purpose / ...) → 自由文本, 不校验
+
+**仍用 Write / Edit 的只剩** (尽量别碰生 JSON, 这两类才用): 骨架首次创建 (Phase 1.2 从 template Write); `instruction` 列表 (套列表结构, 手动 Edit; 透传 directive 用 `--resolve-passthrough`). 改字段 / 删字段 / 改 source 现在都走 wf-patch, **不要再 Read→Edit 改 workflow.json** (那会反复重读、烧 token).
+
+**退出码**: `0` 全通过并写入 (或 `--dry-run` 通过) / `1` 有校验失败 (整批未写) / `2` CLI 错 / 文件不存在 / JSON 损坏.
+
+---
+
+## 3. `spec/tools/render-case.py` — 阶段三 workflow.json → HTML 渲染 + schema 校验
+
+**用途**: 阶段三. 接收 `workflow.json` (多工序 `procedures:[]`), 在内存组装成 case_data (merge `--source-input` 原帖 + `--page-title` + `--case-id`), 跑 schema 校验, 渲染输出 HTML. **不落盘 case_data.json**.
+
+**Agent 用法** (推荐): workflow.json 已含全部 procedures + 标注后:
+
+```bash
+# 1. 只校验, 看 schema 错 (建议带 --source-input, 让校验看到的就是 merge 后的最终版)
+python spec/tools/render-case.py \
+    --workflow outputs/case-{N}/workflow.json \
+    --source-input input/case-{N}-raw.json \
+    --page-title "Case {N} · <主题>" \
+    --case-id {N} \
+    --validate
+
+# 2. 校验通过后渲染输出 (--source-input 必带, 否则 HTML 折叠原文区只剩 60 字 excerpt)
+python spec/tools/render-case.py \
+    --workflow outputs/case-{N}/workflow.json \
+    --source-input input/case-{N}-raw.json \
+    --page-title "Case {N} · <主题>" \
+    --case-id {N} \
+    --out outputs/case-{N}/case-{N}-<slug>.html
+```
+
+**关于 `--source-input`** (2026-05-22 新增): renderer 直接从原帖 raw json 抽 `body_text` + 封面 + 图集兜底, in-place 填到 `case_data.source` —— Agent 不必手工复制原文内容. 行为:
+- `body_text` / `cover_image` 直接覆盖 case_data 同字段
+- `title` / `url` 仅在 case_data 缺时填
+- `excerpt` / `author` / `date` / `platform` **不动** (那些是 Agent 推断的友好版本)
+- **图集兜底**: 检查 raw.body_text 已 inline 的 `[image:URL]` 标记, 把 raw.image_url_list 里没 inline 也不是封面的图 append 到 body 末尾 (加 `--- 附图 ---` 分隔符). 适配"小红书短文 + 多图独立列" 和 "微信公众号长文 + inline 图" 两种平台
+
+**输入契约**: 见 [`spec/format/case-data.schema.json`](format/case-data.schema.json) (canonical JSON Schema Draft 2020-12, 受控字段 + enum + 条件约束 `if/then`).
+
+**模板**: [`spec/templates/workflow.template.json`](templates/workflow.template.json) (复制到 `outputs/case-{N}/workflow.json` 后替换 `<填:...>` 占位符).
+
+⚠ **绝对不要参考其他 `outputs/case-*/build_html.py` 或 `outputs/case-*/*.json`** — 那些是 case-specific 产物, 不是模板. 唯一 canonical 模板是 `spec/templates/workflow.template.json`.
+
+**输出码**:
+- `0` — 成功 (渲染或校验通过)
+- `1` — IO / schema / 渲染异常
+- `2` — CLI 参数错误
+
+**依赖**:
+- 必需: `spec/tools/renderer.py` (跟本脚本同目录, 自动 import)
+- 可选: `jsonschema` (装了用 Draft 2020-12 完整校验; 没装走 minimal check fallback)
+
+---
+
+## 4. `spec/tools/lint-case.py` — 轻量 lint + 自动 record 新 type
+
+**用途**: 阶段三. 跑完 `render-case.py` 后跑一次. 干三件事:
+1. **type 完整性 hint**: 扫 workflow.json 各 procedure 的 IO type 字段, 找出"用了 case-specific type 但该 procedure 的 `type_registry` 漏注册 / 缺 extends / 缺 desc"的情况, 打 stdout 给 Agent / 用户看
+2. **value 自包含 hint**: 扫每个 IO 的 value + 每个 directive, 揪出「(同 sN 输出)」「见 sN」「← sN」这类**引用占位** —— spec 要求 value 逐字回填数据本身 (引用归 anchor), 这种占位 schema/type 检查抓不到. 命中会提示跑 `wf-patch.py --resolve-passthrough` 自动回填
+3. **副作用: auto-record**: 把各 procedure.type_registry 里的 case-specific entry 自动 append 到 `spec/taxonomy/type_suggestions.md` 累积条目段 (Agent **不必手工 Write** suggestions, 工具代劳)
+
+**幂等**: dedup key = `(type_name, case_id)` 二元组. 同 case 重跑不重复; 不同 case 同名允许 (跨 case 频次是升级信号).
+
+**用法**:
+```bash
+# 默认: 检测 + 自动 record
+python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N}
+
+# 只检测不写
+python spec/tools/lint-case.py --workflow outputs/case-{N}/workflow.json --case-id {N} --no-record
+```
+
+**输出范例** (case 含 silent gap 的情况):
+```
+[lint] case-1 (workflow.json)
+  · type 完整性: 2 个提示
+      - [p1] step[1].outputs[0].type='主角图' 是 case-specific 但该 procedure 的 type_registry 没注册
+      - [p1] step[8].inputs[1].type='主角图' 是 case-specific 但该 procedure 的 type_registry 没注册
+  · 无新 type 可 record (type_registry 为空 — 全部 type 命中字典叶子)
+```
+
+**退出码**: 始终 0 (不阻塞流程). 解析 2 = CLI 参数错 / 文件不存在.
+
+**设计哲学**: 不严格. 检测项是 hint 不是 fail; record 是副作用不是核心契约. Agent 看 stdout 决定回不回去补 case_data.type_registry.
+
+---
+
+## 5. `spec/tools/renderer.py` — HTML 渲染主模板 (Python 模块)
+
+**用途**: `render-case.py` 内部 import 的渲染主模板, 1379 行. 提供 `build_html(case_data: dict) -> str` 入口.
+
+**Agent 不直接 Read 它** — 太长, 走 `render-case.py` 调用即可. 直接 Read 它源码会浪费 ~18k context, 没意义.
+
+**Agent 唯一应该看的接口**: [`spec/format/case-data.schema.json`](format/case-data.schema.json) (输入契约) + [`spec/format/procedure-table.md`](format/procedure-table.md) (输出 HTML 的结构规范).
+
+---
+
+## 6. `run_procedure_dsl.py` — runner 本身 (不在 skill 内)
+
+跑 Agent 的入口脚本. **Agent 不读它** — 它在跑 Agent. 但 Agent 可能想知道**自己被怎么起的**:
+
+- OAuth Max 模式 (走 ~/.claude 凭证, 不计 API 费)
+- `allowed_tools = ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Task"]`  # 🟢 包含 Task 召唤子 Agent 工具
+- `permission_mode = "bypassPermissions"` (Agent 全自动, 不停下来问)
+- `cwd = procedure-dsl/` (Agent 的工作目录)
+- `--resume` 支持 (中断后续跑)
+- runner 实时把每 turn 写到 `outputs/case-{N}/_trace.md`
+
+---
+
+## 7. 文件路径约定 (per case)
+
+| 路径 | 内容 | 谁负责 |
+|---|---|---|
+| `input/case-{N}-raw.json` | 原 case 素材 (title / link / body_text / image_url_list / ...) | 人 (Agent 读, 不写) |
+| `outputs/case-{N}/` | Agent 工作目录 (一 case 一目录) | Agent (写产物) |
+| `outputs/case-{N}/.session_id` | SDK session UUID, 用于 `--resume` | runner 写, Agent 不动 |
+| `outputs/case-{N}/_trace.md` | runner 写的实时执行流水 | runner (Agent 别 Read) |
+| `outputs/case-{N}/_scratch/` | **sanctioned scratch 区** — 只用于 dump 大 Bash 输出 (taxonomy --subtree 长结果 / find 结果) 之后 Read 切片, 或一次性 smoke test. runner 预创建. **不要用项目根的 `scratch/`**. ⚠ **不是写 build/normalize 脚本生成 workflow.json 的地方** —— 那个用 §2 `wf-patch.py` | Agent (随便 dump, 跟着 case 一起清理) |
+| `outputs/case-{N}/understanding.md` | 阶段一 1.1 心智模型 (含多工序判断) | Agent |
+| `outputs/case-{N}/workflow.json` | **唯一中间产物** — Phase 1.2 Write 骨架 (从 template), 之后**逐字段演化**: 单处用 Edit, 批量结构化字段 (1.3 anchor / 2 effect·action·type·substance·form) 用 §2 `wf-patch.py`. **绝不写 Python 脚本生成 / 批改它**. 符合 case-data.schema.json (`procedures:[]`) | Agent |
+| `outputs/case-{N}/case-{N}-<slug>.md` | 阶段三 .md 输出 (按 spec/format/md-structure.md) | Agent |
+| `outputs/case-{N}/case-{N}-<slug>.html` | 阶段三 .html 输出 (跑 render-case.py 生成) | render-case.py |
+
+---
+
+## 8. 外部 JSON 词表 (实质 / 形式) 不进 spec/
+
+`分类库导出_*.json` 词表为什么不放 spec/:
+
+- **太大**: 实质 911 条 + 形式 565 条路径, 进 LLM context 占 30k+ tokens
+- **变化频率不同**: spec 演化慢, 词表演化快 (随分类学的迭代)
+- **走 tool 比走 Read 更经济**: 配 `spec/tools/taxonomy-lookup.py` 的 funnel API (`--list-l2 / --subtree / --match`), 一次 query 几百 tokens, 比整树 Read 省 50-100x
+
+Agent 阶段二 2B 完全通过 tool 查, 不要 Read 词表 JSON 本身.
+
+---
+
+## 9. `Agent` (或 `Task`) — Claude Agent SDK 子 Agent 召唤工具
+
+**用途**: 用于在 Phase 2 (归一化与标注) 中将庞大重负载的主 Agent 上下文(含 14+ Base64 图片及 40k+ 规格说明书)与细碎耗费 Token 的高频分类查询进行**上下文隔离与并发分流**。
+
+**工作原理**:
+- 允许主 Agent 创建并行的、无图片和规格历史包袱的轻量化子 Agent。
+- 子 Agent 具有极其干净的小型 Context 窗口(约 2k tokens),它专门携带 `taxonomy-lookup.py` 工具链去查字典,并返回标准的 `patch_2a.json` 或 `patch_2b.json` 回归主 Agent,由主 Agent 通过 `wf-patch.py` 写入 `workflow.json`。
+- **降本 90% 以上,速度提升 100% 以上 (多路并发)**!
+
+**召唤语法与参数**:
+```json
+Agent(
+  subagent_type="Explore",  // 固定为 "Explore" (用于分析与工具调用) 或 "Write" (用于产物生成)
+  description="子任务标题: 例如 Phase 2B 变量实质与形式词表查询",
+  prompt="[给子 Agent 的明确任务书] 例如: 读 outputs/case-N/_scratch/task_2b.json 任务文件,然后为其中各变量调用 taxonomy-lookup.py 查出实质与形式路径,生成标准的 patch_2b.json 并把其具体内容写回给我。"
+)
+```

+ 189 - 0
examples/process_pipeline/script/procedure-dsl/spec/tools/prepare-subtask.py

@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+prepare-subtask.py — Phase 2 子 Agent 任务切片提取工具.
+
+功能:
+  解析 workflow.json 与 input/case-N.json, 提取出 Phase 2A 和 Phase 2B 子任务所需的最小切片。
+  特别支持「按需图片分流」: 自动识别 IO 变量所关联的图片编号 (如 图05), 并从输入文件中映射出对应的 URL/本地路径。
+"""
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+# Configure UTF-8
+for stream in (sys.stdout, sys.stderr):
+    if hasattr(stream, 'reconfigure'):
+        try:
+            stream.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+
+def parse_args():
+    ap = argparse.ArgumentParser(description="Phase 2 子 Agent 任务切片提取工具")
+    ap.add_argument("--workflow", type=Path, required=True, help="目标 workflow.json 路径")
+    ap.add_argument("--source", type=Path, required=True, help="对应的输入 case JSON 路径")
+    ap.add_argument("--out-dir", type=Path, required=True, help="输出任务切片的目标文件夹 (通常是 outputs/case-N/_scratch)")
+    return ap.parse_args()
+
+def extract_image_refs(text: str, image_list: list) -> list:
+    """
+    通过正则匹配文本中的“图05”或“图 05”等字样,映射到输入 JSON 的 image_url_list 对应图片。
+    """
+    if not text or not image_list:
+        return []
+    
+    # 匹配 "图05" "图 05" "图1" "图 10" 等
+    matches = re.findall(r'图\s*0?(\d+)', text)
+    if not matches:
+        return []
+    
+    refs = []
+    for m in matches:
+        try:
+            idx = int(m) - 1  # 通常文章中 "图1" 对应图集里的第1张图 (1-indexed)
+            if 0 <= idx < len(image_list):
+                img_item = image_list[idx]
+                if isinstance(img_item, dict) and "image_url" in img_item:
+                    refs.append(img_item["image_url"])
+                elif isinstance(img_item, str):
+                    refs.append(img_item)
+        except Exception:
+            pass
+    return sorted(list(set(refs)))
+
+def main():
+    args = parse_args()
+    
+    if not args.workflow.exists():
+        sys.exit(f"Error: workflow.json 不存在: {args.workflow}")
+    if not args.source.exists():
+        sys.exit(f"Error: source json 不存在: {args.source}")
+        
+    # Create outputs folder if not exist
+    args.out_dir.mkdir(parents=True, exist_ok=True)
+    
+    try:
+        wf_data = json.loads(args.workflow.read_text(encoding='utf-8'))
+    except Exception as e:
+        sys.exit(f"Error reading workflow.json: {e}")
+        
+    try:
+        src_data = json.loads(args.source.read_text(encoding='utf-8'))
+    except Exception as e:
+        sys.exit(f"Error reading source JSON: {e}")
+        
+    image_list = src_data.get("image_url_list", [])
+    
+    # ──── 提取 2A 子任务 (作用/动作/类型归一化) ──────────────────────────────────
+    task_2a = {
+        "case_id": args.workflow.parent.name,
+        "task": "Phase 2A (effect/action/type normalization)",
+        "image_url_list": image_list,
+        "steps": []
+    }
+    
+    # ──── 提取 2B 子任务 (实质/形式词表查询匹配) ──────────────────────────────────
+    task_2b = {
+        "case_id": args.workflow.parent.name,
+        "task": "Phase 2B (substance/form matching)",
+        "image_url_list": image_list,
+        "items_to_match": []
+    }
+    
+    for p_idx, proc in enumerate(wf_data.get("procedures", [])):
+        proc_id = proc.get("id", f"p{p_idx+1}")
+        
+        # 2A: 包含 type_registry,供自定义类型校验
+        type_registry = proc.get("type_registry", {})
+        
+        for s in proc.get("steps", []):
+            sid = s.get("id")
+            if not sid:
+                continue
+                
+            # 2A Step Item
+            step_item_2a = {
+                "path": f"{proc_id}.{sid}",
+                "name": s.get("name", ""),
+                "action": s.get("action", ""),
+                "effect": s.get("effect", ""),
+                "kind": s.get("kind", "step"),
+                "type_registry": type_registry,
+                "inputs": [],
+                "outputs": []
+            }
+            
+            # Extract inputs
+            for idx, io in enumerate(s.get("inputs", [])):
+                name = io.get("name", "")
+                val = io.get("value", "")
+                io_type = io.get("type", "")
+                
+                # 2B Variable Item
+                related_imgs = extract_image_refs(val, image_list)
+                # 也从 name 里提取,防漏
+                name_imgs = extract_image_refs(name, image_list)
+                all_imgs = sorted(list(set(related_imgs + name_imgs)))
+                
+                step_item_2a["inputs"].append({
+                    "path": f"{proc_id}.{sid}.inputs[{idx}]",
+                    "name": name,
+                    "value": val,
+                    "type": io_type,
+                    "related_images": all_imgs
+                })
+                
+                task_2b["items_to_match"].append({
+                    "path": f"{proc_id}.{sid}.inputs[{idx}]",
+                    "name": name,
+                    "value": val,
+                    "type": io_type,
+                    "related_images": all_imgs
+                })
+                
+            # Extract outputs
+            for idx, io in enumerate(s.get("outputs", [])):
+                name = io.get("name", "")
+                val = io.get("value", "")
+                io_type = io.get("type", "")
+                
+                # 2B Variable Item
+                related_imgs = extract_image_refs(val, image_list)
+                name_imgs = extract_image_refs(name, image_list)
+                all_imgs = sorted(list(set(related_imgs + name_imgs)))
+                
+                step_item_2a["outputs"].append({
+                    "path": f"{proc_id}.{sid}.outputs[{idx}]",
+                    "name": name,
+                    "value": val,
+                    "type": io_type,
+                    "related_images": all_imgs
+                })
+                
+                task_2b["items_to_match"].append({
+                    "path": f"{proc_id}.{sid}.outputs[{idx}]",
+                    "name": name,
+                    "value": val,
+                    "type": io_type,
+                    "related_images": all_imgs
+                })
+                
+            task_2a["steps"].append(step_item_2a)
+
+    # 落盘 task_2a.json 与 task_2b.json
+    file_2a = args.out_dir / "task_2a.json"
+    file_2b = args.out_dir / "task_2b.json"
+    
+    file_2a.write_text(json.dumps(task_2a, ensure_ascii=False, indent=2), encoding='utf-8')
+    file_2b.write_text(json.dumps(task_2b, ensure_ascii=False, indent=2), encoding='utf-8')
+    
+    print(f"[success] Generated subtask files in {args.out_dir}:")
+    print(f"  - task_2a.json ({len(task_2a['steps'])} steps)")
+    print(f"  - task_2b.json ({len(task_2b['items_to_match'])} items mapped with images)")
+
+if __name__ == "__main__":
+    main()

+ 15 - 1
examples/process_pipeline/script/procedure-dsl/spec/tools/renderer.py

@@ -183,7 +183,21 @@ def render_chip(type_name):
 def render_path(prefix, value):
     if not value:
         return ''
-    return f'<span data-prefix="{prefix}" data-value="{he(value)}">{he(value)}</span>'
+    if isinstance(value, list):
+        spans = []
+        for val in value:
+            if val:
+                spans.append(f'<span data-prefix="{prefix}" data-value="{he(val)}">{he(val)}</span>')
+        return '\n'.join(spans)
+    if isinstance(value, str):
+        if '+' in value:
+            parts = [p.strip() for p in value.split('+') if p.strip()]
+            spans = []
+            for val in parts:
+                spans.append(f'<span data-prefix="{prefix}" data-value="{he(val)}">{he(val)}</span>')
+            return '\n'.join(spans)
+        return f'<span data-prefix="{prefix}" data-value="{he(value)}">{he(value)}</span>'
+    return ''
 
 
 _VALUE_DESC_RE = re.compile(r'^<(.+)>$', re.DOTALL)

+ 4 - 4
examples/process_pipeline/script/procedure-dsl/spec/tools/script.js

@@ -57,12 +57,12 @@ document.querySelectorAll('.chip[data-type]').forEach(c => {
     if (t.extends) parts.push(`<div class="row"><b>extends</b>: ${escapeHtml(t.extends)}</div>`);
     if (t.desc) parts.push(`<div class="row"><b>描述</b>: ${escapeHtml(t.desc)}</div>`);
     parts.push(`<h3>字典树 (§A.3 类型词表)</h3>`);
-    if (isInTypeTree(tp)) {
+    if (t && t.in_tree) {
       parts.push(`<div class="row" style="color:#047857;">✓ <b>${escapeHtml(tp)}</b> 是字典树叶子</div>`);
-    } else if (t.extends && isInTypeTree(t.extends)) {
-      parts.push(`<div class="warning">${escapeHtml(tp)} 不在字典树叶子里, 但 extends <b>${escapeHtml(t.extends)}</b> (字典树叶子). 这是 case-specific 类型扩展.</div>`);
+    } else if (t && t.extends && isInTypeTree(t.extends)) {
+      parts.push(`<div class="row" style="color:#0369a1;">✓ <b>${escapeHtml(tp)}</b> 是自定义拓展节点 (继承自: <b>${escapeHtml(t.extends)}</b>)</div>`);
     } else {
-      parts.push(`<div class="warning">${escapeHtml(tp)} 不在字典树叶子, 也无 extends 桥接.</div>`);
+      parts.push(`<div class="warning">${escapeHtml(tp)} 不在字典树叶子, 也无合法的 extends 桥接.</div>`);
     }
     parts.push(`<div class="tree">${renderTree(taxonomy['类型'].tree, tp)}</div>`);
     openDrawer(`类型 · ${tp}`, parts.join(''));

+ 9 - 0
examples/process_pipeline/script/procedure-dsl/spec/tools/taxonomy-lookup.py

@@ -35,6 +35,15 @@ DIM_PATTERNS = {
     '形式': '分类库导出_形式_*.json',
 }
 
+# Windows 控制台 UTF-8
+for _s in (sys.stdout, sys.stderr):
+    if hasattr(_s, 'reconfigure'):
+        try:
+            _s.reconfigure(encoding='utf-8', errors='replace')
+        except Exception:
+            pass
+
+
 # DSL_ROOT = procedure-dsl/  (本脚本位于 procedure-dsl/spec/tools/)
 # 词表 bundled 在 spec/taxonomy/ 里, skill 自包含.
 DSL_ROOT = Path(__file__).resolve().parent.parent.parent

+ 180 - 43
examples/process_pipeline/script/procedure-dsl/spec/tools/wf-patch.py

@@ -27,25 +27,32 @@ wf-patch.py — workflow.json 的安全批量字段设置器.
     # 只校验不写
     python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
 
-路径语法 (proc / step 按 id 寻址, 不是下标; 只有真列表才用 [i]):
-    p1.s2.effect                      step 标量字段 (effect/via/action/feature/control/kind/intent/group ...)
+    # 删字段 (取代手 Edit 删; 字段不存在则幂等跳过)
+    python spec/tools/wf-patch.py --workflow ... --unset 'p1.declarations.inputs[0].inferred'
+
+    # 只校验不写
+    python spec/tools/wf-patch.py --workflow ... --set '...' --dry-run
+
+路径语法 (proc / step 按 id 寻址, 不是下标; 只有真列表才用 [i]; 嵌套步 id 带点 s2.1 也支持):
+    p1.s2.effect                      step 标量字段 (effect/via/action/feature/control/kind/intent/group)
     p1.s1.inputs[0].anchor            IO 字段 (anchor/type/substance/form/name/value)
-    p1.s1.outputs[0].type
+    p1.s2.focus                       step 的 focus 数组 (逗号分隔: focus=via,action,out-type-0)
     p1.purpose                        procedure 头部字段 (name/purpose/category/platform/author)
+    p1.declarations.inputs[0].desc    declarations 内任意字段 (通用下钻)
+    source.url                        case-level 原帖信息 (platform/author/date/url/title/excerpt)
     p1.type_registry.场景图.extends    注册 case-specific 类型 (会自动建 type_registry 段)
-    p1.type_registry.场景图.desc
 
 value 特殊取值:
-    __null__   -> JSON null (用于 substance/form 可空)
+    __null__   -> JSON null (用于 substance/form/url 可空)
 
-不在职责内 (仍用 Write / Edit):
+仍用 Write / Edit 的只剩 (尽量别碰生 JSON):
     - workflow.json 骨架的首次创建 (Phase 1.2 从 template Write)
-    - instruction (列表套列表, 1-2 行手动 Edit 即可)
-    - 删字段 / 删 step / 调结构
+    - instruction (列表套列表, 手动 Edit; 透传 directive 用 --resolve-passthrough)
+    改字段/删字段/改 source 现在都走本工具, 不要再 Read→Edit 改 workflow.json (会反复重读、烧 token).
 
 退出码:
-    0  全部赋值校验通过并写入 (--dry-run 时为校验通过)
-    1  有赋值校验失败 (整批未写) / 路径解析失败
+    0  全部校验通过并写入 (--dry-run 时为校验通过)
+    1  有校验失败 (整批未写) / 路径解析失败
     2  CLI 参数错误 / 文件不存在 / JSON 损坏
 """
 from __future__ import annotations
@@ -127,9 +134,12 @@ def _taxo_valid(dim: str, path: str) -> bool:
     if key in _taxo_cache:
         return _taxo_cache[key]
     try:
+        import os
+        env = os.environ.copy()
+        env['PYTHONIOENCODING'] = 'utf-8'
         r = subprocess.run(
             [sys.executable, str(LOOKUP), '--dim', dim, '--validate', path],
-            capture_output=True, text=True,
+            capture_output=True, text=True, encoding='utf-8', errors='replace', env=env,
         )
         ok = (r.returncode == 0)
     except Exception:
@@ -138,6 +148,7 @@ def _taxo_valid(dim: str, path: str) -> bool:
     return ok
 
 
+
 def _closest(name: str, leaves) -> str:
     """给个最接近的叶子名做提示 (子串/前缀朴素匹配, 仅供报错文案)."""
     cands = [lf for lf in leaves if name and (name in lf or lf in name)]
@@ -148,12 +159,17 @@ def _closest(name: str, leaves) -> str:
 # 字段校验 -> (ok, normalized_value, err_msg)
 # ===========================================================================
 
-def validate_field(field: str, value, proc: dict):
-    # null 哨兵 (substance/form 可空)
+def validate_field(field: str, value, proc: dict, pending_types: set[str] = None):
+    # null 哨兵 (substance/form/url 可空)
     if value == '__null__':
-        if field in ('substance', 'form'):
+        if field in ('substance', 'form', 'url'):
             return True, None, ''
-        return False, value, f'__null__ 只对 substance/form 有意义, {field} 不可为 null'
+        return False, value, f'__null__ 只对 substance/form/url 有意义, {field} 不可为 null'
+
+    # focus 是数组: 逗号分隔 → list ('via,action,out-type-0'); 空串 → []
+    if field == 'focus':
+        items = [t.strip() for t in str(value).split(',') if t.strip()]
+        return True, items, ''
 
     if field == 'effect':
         if value in EFFECT_LEAVES:
@@ -179,6 +195,8 @@ def validate_field(field: str, value, proc: dict):
         reg = proc.get('type_registry') or {}
         if value in reg:
             return True, value, ''
+        if pending_types and value in pending_types:
+            return True, value, ''
         return False, value, (f'type={value!r} 不是 type.json 叶子, 也没在本工序 type_registry 注册. '
                               f'先 --set {proc.get("id")}.type_registry.{value}.extends=<叶子> 再用.{_closest(value, TYPE_LEAVES)}')
 
@@ -188,14 +206,46 @@ def validate_field(field: str, value, proc: dict):
         return False, value, f'type_registry extends={value!r} 必须是 type.json 叶子.{_closest(value, TYPE_LEAVES)}'
 
     if field == 'substance':
-        if _taxo_valid('实质', value):
-            return True, value, ''
-        return False, value, f'substance={value!r} 不在实质词表 (taxonomy-lookup --dim 实质 --subtree 查可用叶子)'
+        if isinstance(value, str):
+            if '+' in value:
+                paths = [p.strip() for p in value.split('+') if p.strip()]
+            else:
+                paths = [value.strip()]
+        elif isinstance(value, list):
+            paths = [str(p).strip() for p in value if str(p).strip()]
+        else:
+            return False, value, 'substance 必须是字符串或数组'
+
+        invalid_paths = []
+        for p in paths:
+            if not _taxo_valid('实质', p):
+                invalid_paths.append(p)
+        if invalid_paths:
+            return False, value, f'以下 substance 路径不在实质词表: {invalid_paths}'
+        
+        norm_val = paths if (isinstance(value, list) or (isinstance(value, str) and '+' in value)) else paths[0]
+        return True, norm_val, ''
 
     if field == 'form':
-        if _taxo_valid('形式', value):
-            return True, value, ''
-        return False, value, f'form={value!r} 不在形式词表 (taxonomy-lookup --dim 形式 --subtree 查可用叶子)'
+        if isinstance(value, str):
+            if '+' in value:
+                paths = [p.strip() for p in value.split('+') if p.strip()]
+            else:
+                paths = [value.strip()]
+        elif isinstance(value, list):
+            paths = [str(p).strip() for p in value if str(p).strip()]
+        else:
+            return False, value, 'form 必须是字符串或数组'
+
+        invalid_paths = []
+        for p in paths:
+            if not _taxo_valid('形式', p):
+                invalid_paths.append(p)
+        if invalid_paths:
+            return False, value, f'以下 form 路径不在形式词表: {invalid_paths}'
+        
+        norm_val = paths if (isinstance(value, list) or (isinstance(value, str) and '+' in value)) else paths[0]
+        return True, norm_val, ''
 
     if field == 'anchor':
         if re.match(r'^\s*(←|→)', str(value)):
@@ -235,6 +285,35 @@ def _split_seg(seg: str):
     return m.group(1), (int(m.group(2)) if m.group(2) is not None else None)
 
 
+def _descend(container, segs):
+    """沿 segs 走进 container, 返回 (parent, last_key). 中间节点必须已存在.
+
+    segs 每段可带 [i] 下标. last_key 是 dict 键 (str) 或列表下标 (int);
+    设置即 parent[last_key]=value, 删除即 del parent[last_key].
+    用于 source.* / declarations.* 等通用路径 (proc/step 的 id 寻址不走这里).
+    """
+    cur = container
+    for i, seg in enumerate(segs):
+        name, idx = _split_seg(seg)
+        last = (i == len(segs) - 1)
+        if last and idx is None:
+            if not isinstance(cur, dict):
+                raise PathError(f'{name!r} 的父级不是对象')
+            return cur, name
+        if not isinstance(cur, dict) or name not in cur:
+            raise PathError(f'路径段 {name!r} 不存在, 无法下钻')
+        nxt = cur[name]
+        if idx is not None:
+            if not isinstance(nxt, list) or idx >= len(nxt):
+                raise PathError(f'{name}[{idx}] 越界或非列表')
+            if last:
+                return nxt, idx
+            cur = nxt[idx]
+        else:
+            cur = nxt
+    raise PathError('路径为空')
+
+
 def locate(data: dict, path: str):
     """把 path 解析到目标. 返回 (parent, key, proc, field_name).
 
@@ -243,9 +322,16 @@ def locate(data: dict, path: str):
     step id 可能带点 (嵌套步 s2.1) — 用最长前缀匹配消歧 (s2.1 优先于 s2).
     """
     if '.' not in path:
-        raise PathError(f'路径太短 {path!r}, 至少 <proc>.<字段>')
+        raise PathError(f'路径太短 {path!r}, 至少 <proc>.<字段> 或 source.<字段>')
 
     proc_id, remainder = path.split('.', 1)
+
+    # --- source.* 分支 (case-level 原帖信息, 无 proc 上下文) ---
+    if proc_id == 'source':
+        src = data.setdefault('source', {})
+        parent, key = _descend(src, remainder.split('.'))
+        return parent, key, None, (key if isinstance(key, str) else '')
+
     proc = next((p for p in (data.get('procedures') or []) if p.get('id') == proc_id), None)
     if proc is None:
         ids = [p.get('id') for p in (data.get('procedures') or [])]
@@ -289,11 +375,9 @@ def locate(data: dict, path: str):
                 raise PathError(f'step 标量字段形如 {proc_id}.{sid}.{name2}')
             return matched, name2, proc, name2
 
-    # --- procedure 头部字段 (单段) ---
-    if '.' not in remainder:
-        return proc, remainder, proc, remainder
-
-    raise PathError(f'无法解析 {path!r}: {remainder.split(".")[0]!r} 既不是 {proc_id} 的 step id, 也不是单段 proc 字段')
+    # --- proc 内其余路径: 头部字段 / declarations.* / return_row.* 等, 走通用下钻 ---
+    parent, key = _descend(proc, remainder.split('.'))
+    return parent, key, proc, (key if isinstance(key, str) else '')
 
 
 # ===========================================================================
@@ -427,13 +511,34 @@ def resolve_passthrough(data: dict):
 # ===========================================================================
 
 def load_patches(args) -> list[tuple[str, str]]:
-    """汇总 --set 与 --patch 成 [(path, value), ...]."""
+    """汇总 --set、--patch 与 --set-file 成 [(path, value), ...]."""
+    def _norm(v):
+        if isinstance(v, str):
+            # 将中文全角双角/单引号自动归一化为标准半角引号,更利于 AI 生图引擎和 Prompt 语法识别
+            v = v.replace('“', '"').replace('”', '"').replace('‘', "'").replace('’', "'")
+        return v
+
     out: list[tuple[str, str]] = []
     for s in args.set or []:
         if '=' not in s:
             raise SystemExit(f'wf-patch: --set 缺 "=" : {s!r} (形如 path=value)')
         path, value = s.split('=', 1)        # 只切第一个 '='
-        out.append((path.strip(), value))
+        out.append((path.strip(), _norm(value)))
+
+    # 🟢 新增:从外部文件读取值注入
+    for sf in getattr(args, 'set_file', None) or []:
+        if '=' not in sf:
+            raise SystemExit(f'wf-patch: --set-file 缺 "=" : {sf!r} (形如 path=file_path)')
+        path, fpath_str = sf.split('=', 1)
+        fpath = Path(fpath_str.strip())
+        if not fpath.exists():
+            raise SystemExit(f'wf-patch: --set-file 指定的文件不存在: {fpath_str}')
+        try:
+            value = fpath.read_text(encoding='utf-8')
+        except Exception as e:
+            raise SystemExit(f'wf-patch: 无法读取 --set-file 指定的文件 {fpath_str}: {e}')
+        out.append((path.strip(), _norm(value)))
+
     if args.patch:
         if not args.patch.exists():
             raise SystemExit(f'wf-patch: --patch 文件不存在 {args.patch}')
@@ -442,7 +547,7 @@ def load_patches(args) -> list[tuple[str, str]]:
         except json.JSONDecodeError as e:
             raise SystemExit(f'wf-patch: --patch 不是合法 JSON: {e}')
         for it in items:
-            out.append((it['path'], it['value']))
+            out.append((it['path'], _norm(it['value'])))
     return out
 
 
@@ -456,6 +561,10 @@ def main() -> None:
                     help='单条赋值, 可重复. 只在第一个 = 处切; value 可含 = 和空格 (记得整体加引号)')
     ap.add_argument('--patch', type=Path, default=None,
                     help='批量赋值清单 .json: [{"path":..,"value":..}, ...]')
+    ap.add_argument('--set-file', action='append', metavar='PATH=FILE_PATH', default=None,
+                    help='从外部文件读取内容注入指定字段. e.g. p1.s1.outputs[0].value=_scratch/prompt.txt')
+    ap.add_argument('--unset', action='append', metavar='PATH', default=None,
+                    help='删字段, 可重复. e.g. p1.declarations.inputs[0].inferred (字段不存在则跳过). 取代手 Edit 删字段')
     ap.add_argument('--resolve-passthrough', action='store_true',
                     help='把 anchor 为纯透传 (← sN.varname)、value 仍空/占位的 IO, 顺 anchor 从源 output 逐字抄 value. 可单独跑, 也可跟在 --set/--patch 后 (先赋值再解析). 迭代处理链式透传')
     ap.add_argument('--dry-run', action='store_true', help='只校验/预演, 不写')
@@ -472,12 +581,21 @@ def main() -> None:
         sys.exit(2)
 
     patches = load_patches(args)
-    if not patches and not args.resolve_passthrough:
-        print('wf-patch: 没有 --set / --patch / --resolve-passthrough, 啥也没干', file=sys.stderr)
+    unsets = args.unset or []
+    if not patches and not unsets and not args.resolve_passthrough:
+        print('wf-patch: 没有 --set / --patch / --unset / --resolve-passthrough, 啥也没干', file=sys.stderr)
         sys.exit(2)
 
-    # 先全部解析 + 校验, 收集计划; 任何一条失败 -> 整批不写
-    plan = []          # (parent, key, normalized_value, path, display)
+    # 解析 + 校验; 任何一条失败 -> 整批不写
+    pending_types = set()
+    for path, _ in patches:
+        m = re.match(r'^p\d+\.type_registry\.([^.]+)\.(extends|desc)$', path)
+        if m:
+            pending_types.add(m.group(1))
+
+    plan = []          # set: (parent, key, normalized_value, path, display)
+    del_plan = []      # unset: (parent, key, path)
+    skipped = []       # unset 跳过 (字段本就不在)
     errors = []        # (path, msg)
     for path, value in patches:
         try:
@@ -485,25 +603,44 @@ def main() -> None:
         except PathError as e:
             errors.append((path, str(e)))
             continue
-        ok, norm, msg = validate_field(field, value, proc)
+        ok, norm, msg = validate_field(field, value, proc, pending_types)
         if not ok:
             errors.append((path, msg))
             continue
         plan.append((parent, key, norm, path, norm if norm is not None else 'null'))
-
-    if patches:
-        print(f'[wf-patch] {wf.name} — {len(patches)} 条赋值, {len(plan)} 通过, {len(errors)} 失败')
-        for _parent, _key, _norm, path, disp in plan:
-            print(f'  ✓ {path} = {disp}')
+    for path in unsets:
+        try:
+            parent, key, _proc, _field = locate(data, path)
+        except PathError as e:
+            errors.append((path, str(e)))
+            continue
+        present = (isinstance(parent, dict) and key in parent) or \
+                  (isinstance(parent, list) and isinstance(key, int) and key < len(parent))
+        (del_plan if present else skipped).append((parent, key, path) if present else path)
+
+    if patches or unsets:
+        print(f'[wf-patch] {wf.name} — set {len(plan)}/{len(patches)} 通过, '
+              f'unset {len(del_plan)} 删/{len(skipped)} 跳过, {len(errors)} 失败')
+        for _p, _k, _n, path, disp in plan:
+            print(f'  ✓ set   {path} = {disp}')
+        for _p, _k, path in del_plan:
+            print(f'  ✓ unset {path}')
+        for path in skipped:
+            print(f'  · skip  {path} (字段本就不存在)')
         for path, msg in errors:
             print(f'  ✗ {path}  — {msg}')
         if errors:
-            print(f'\n有 {len(errors)} 条校验失败, 整批未写入 (修正后重跑).', file=sys.stderr)
+            print(f'\n有 {len(errors)} 条失败, 整批未写入 (修正后重跑).', file=sys.stderr)
             sys.exit(1)
 
-    # 赋值先落到内存 data (resolve 要看到它们); 是否持久化由 dry-run 决定
+    # 应用到内存 data (set 先 unset 后; resolve 要看到这些改动). 是否持久化由 dry-run 决定.
     for parent, key, norm, _, _ in plan:
         parent[key] = norm
+    for parent, key, _path in sorted(del_plan, key=lambda d: -d[1] if isinstance(d[1], int) else 0):
+        if isinstance(parent, list):
+            parent.pop(key)
+        else:
+            del parent[key]
 
     # 透传回填
     filled, warns = [], []
@@ -515,7 +652,7 @@ def main() -> None:
         for w in warns:
             print(f'  ⚠ {w}')
 
-    n_changes = len(plan) + len(filled)
+    n_changes = len(plan) + len(del_plan) + len(filled)
     if args.dry_run:
         print(f'\n--dry-run: 预演 {n_changes} 处改动, 未写入.')
         sys.exit(0)