# 动态工作流（一）：门面与编译器

> 主 Agent 用 TypeScript 写工作流脚本：门面 API 长什么样，编译器怎样在纯内存的虚拟宿主里做类型检查、报出 9001～9009 诊断，用 taint 不动点和时序遍历算出四种图，再把结果类型合成 JSON Schema、把脚本降级成沙箱能跑的 JavaScript。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.net/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.net/manual/zcode/dwf-compiler
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

动态工作流（dynamic workflow，代码里常缩写成 dwf）是 ZCode 编排多个子 Agent 的方式：主 Agent 不一个个地派活，而是写一段 TypeScript 脚本，脚本里用 `agent()` 建子会话、用 `ask<T>()` 派任务，再用普通的循环、分支和 `Promise.all` 把它们串起来。编译器先做类型检查和静态分析，用户看过分析出的阶段图并确认，脚本才被降级成 JavaScript，放进沙箱子进程里执行。它和[子 Agent](https://daiw.net/manual/zcode/subagents)那种一次委托一件事的做法不同，也和[专家工作流](https://daiw.net/manual/zcode/expert-workflow)那种阶段固定的图调度不同：编排逻辑是模型当场写出来的代码。

整个功能分三篇。本篇讲纯函数库 `@zcode/dynamic-workflow`（`apps/zcode-cli/packages/dynamic-workflow`）的前半截：给模型看的门面、编译器、静态分析、schema 合成与 lowering。同一个包里的执行引擎、沙箱与日志回放在[下一篇](https://daiw.net/manual/zcode/dwf-engine)；工具、子会话与落库在[第三篇](https://daiw.net/manual/zcode/dwf-tools)。

## 为什么让模型写脚本

包的 README 开头一句话交代了分工（`apps/zcode-cli/packages/dynamic-workflow/README.md:3`）：

> Self-contained library for the dynamic workflow feature: the TypeScript facade the main agent writes scripts against, and the compiler that recovers rigor from those scripts (typecheck, schema synthesis, dependency inference, site identity).

代码里没有一段正面比较“脚本、JSON、DSL 哪个好”的文字。从代码看，选脚本的理由有三层：

- **表达力**。`CreateWorkflow` 的工具说明把卖点写成“plain control flow (loops, conditionals, fan-out) and typed intermediate results”（`apps/zcode-cli/packages/core/src/tool/handlers/create-workflow-description.ts:8`）。按结果重试、按文件逐个评审再逐条复核，用普通代码就是几行。同仓库的专家工作流走的是另一条路：固定的八个阶段加一个图调度器，规划器只能按固定 schema 返回 JSON 形式的扩图，内容是节点、边与集合（`apps/zcode-cli/packages/core/src/workflow/expert/parsers/planner-result.ts:22`），能编排什么受这套结构约束。
- **模型写得顺**。编译选项按模型的习惯调过：`strict` 开着，`noUncheckedIndexedAccess` 却刻意关掉，注释说训练语料几乎都关着它，开着时 `items[i]` 引起的 TS2532、TS18048 占了本地运行里 undefined 类诊断的 96%（`apps/zcode-cli/packages/dynamic-workflow/src/compiler/compile.ts:42`）。
- **严谨性由编译器找回来**。类型实参就是结果的 schema，调用点就是日志的键，`world.run` 的命令字面量就是授权清单。脚本可以写得随意，能动手的地方都被钉死。

这里反复出现的“站点”（site），指脚本源码里的一处门面调用。编译器按种类、按源码顺序给它编号：`ask#1`、`actor#2`、`world-read#1`、`join#1`、`report#1`、`artifact#1`（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/sites.ts:310`、`sites.ts:349`、`sites.ts:366`、`sites.ts:399`、`sites.ts:417`、`sites.ts:331`）。这些编号不只是展示用的坐标：日志以“站点 × 第几次执行”为键，逐字节相同的脚本恢复运行时就靠它回放（下一篇）；只有修订后的脚本导入前驱结果时，才改按“actor 名字 + 这个 actor 的第几个 ask + 输入哈希”匹配、不看位置（第三篇）。README 说站点编号“used as display and graph coordinates only”（`README.md:144`），只对后一种情况成立。

## 包的边界与目录

README 把纯度写成硬约束（`README.md:9`）：

> This package is **pure**: no session spawning, no storage, no disk or network I/O (the TS stdlib is embedded, not read from disk — see "Embedded libs"). It never imports from `@zcode/core` or `@zcode/bootstrap`.

核对源码，`src/` 下唯一的外部依赖是 `typescript`（`apps/zcode-cli/packages/dynamic-workflow/package.json:27`）。

| 目录 | 职责 |
| --- | --- |
| `src/facade/` | 门面 `.d.ts`（字符串常量）、门面成员注册表、各类上限常量 |
| `src/compiler/` | 虚拟宿主里的类型检查，`createWorkflowProgram` 是其余各趟共用的底座 |
| `src/analysis/` | 站点表、编写期诊断、解释器、四种投影、Mermaid 与文本序列化 |
| `src/schema/` | `ask<T>` 到 JSON Schema、子集校验器、每个 actor 的 submit profile |
| `src/lowering/` | 擦类型、插站点 ID，产出沙箱的输入 |
| `src/engine/` | 执行引擎核心，见[下一篇](https://daiw.net/manual/zcode/dwf-engine) |
| `src/projections.ts` | 不 import `typescript` 的投影子集，给浏览器端在冻结的分析结果上重算图 |

README 还描述了 `tests/workflows/` 与 `tests/graphs/` 两套夹具和 `pnpm test`（`README.md:74`），但开源版本里没有 `tests/` 目录，`package.json` 也没有 `test` 脚本（`package.json:18`），只剩一个调试脚本 `scratch/run.mjs`。所以下文的例子是笔者按门面写的，并把克隆里的这个包复制出来编译后实际跑过。

## 门面：一个名词，一个动词

门面是一份 `.d.ts` 文本，以字符串常量的形式嵌在包里，文件名固定为 `workflow-facade.d.ts`（`apps/zcode-cli/packages/dynamic-workflow/src/facade/dts.ts:15`）。它的自我定位是“One noun (the actor), one verb (the task).”（`dts.ts:6`）。核心只有两个声明（`dts.ts:35`）：

```ts
/**
 * An actor: a persistent conversational context that executes tasks serially.
 * Context accumulates across asks; concurrent asks on one actor queue FIFO.
 */
declare interface Agent {
  /**
   * Assign one task. T is the task's output value: an interface you define in
   * this script with a plain "interface" declaration (no "declare" modifier; the
   * harness synthesizes its runtime schema from the type), or the final response
   * text when the type argument is omitted.
   */
  ask<T = string>(instructions: string): Node<T>;
}
  // ...
declare function agent(name?: string, persona?: string | AgentPersona): Agent;
```

完整的门面由八个命名段拼接而成（`dts.ts:427`）。`EvalWorkflowSnippet` 用的试验台门面只取其中的实参、日志、世界读取与 `world.run` 四段（`dts.ts:448`），所以片段里写 `agent()` 会直接得到“Cannot find name”。门面还原样拼进了 `CreateWorkflow` 的工具说明（`create-workflow-description.ts:103`），模型看到的 API 与编译器检查的是同一份文本。全部成员：

| 成员 | 作用 | 要点 |
| --- | --- | --- |
| `agent(name?, persona?)` | 建一个 actor，即一个持久子会话 | 非空名字是身份：同一 run 内重名整个 run 失败，修订重跑也按名字复用缓存（`dts.ts:53`） |
| `Agent.ask<T>(instructions)` | 派一个任务，返回 `Node<T>` | 同一 actor 上的并发 ask 按先进先出排队；省略类型实参时结果是最终回复文本 |
| `Promise.all` / `allSettled` | 汇合 | 不是门面函数，站点表按全局 `Promise` 识别成 join 站点（`sites.ts:360`） |
| `log(message)` | 进度消息 | 没有站点，不写节点行，只作为一条事件记下 |
| `report(item, artifactId?)` | 发布一条中间结果 | 进日志；每 run 最多 256 条、单条序列化后不超过 32KB，超了整个 run 失败（`dts.ts:86`） |
| `artifact.file` / `markdown` | 发布交付物 | 异步、失败可 `catch`；每 run 32 个 id、每个 id 16 版、单文件 20 MiB（`dts.ts:188`） |
| `artifact.chart` / `table` / `metrics` / `board` | 声明看板 | 同步声明，由带标签的 `report` 喂数据 |
| `phase(name)` | 阶段标记 | 名字须是字面量、调用须独立成句 |
| `files.glob` / `read` / `grep` | 只读观察工作区 | 进日志；glob 与 grep 上限 2000 条，grep 结果不超过 256KB，超限拒绝而不截断（`dts.ts:293`） |
| `git.changedFiles` / `diff` / `status` / `log` | 只读 git 观察 | 固定 argv、不经 shell；diff 上限 512KB，log 最多 100 条（`dts.ts:342`） |
| `world.run(cmd, args?, opts?)` | 执行命令 | `cmd` 须是字面量；非零退出码作为返回值而不是异常；默认超时 300000 毫秒、不设上限（`dts.ts:388`） |
| `args` | 运行实参 | 保存的工作流按声明校验后注入，内联脚本恒为空对象（`dts.ts:424`） |

门面里没有 `createActor`、`join` 这类名字：`createActor` 是降级后的宿主调用，汇合就是 `Promise.all`。门面注释还要求“每个阶段至少含一个 ask 或一次 `world.run`”（`dts.ts:247`），这条只是写作规范，编译器不检查：笔者试过一个只有普通语句的阶段，零诊断，它只是不出现在阶段图里。

## 一个完整的脚本

下面这份脚本是笔者按门面写的示意：并行评审改动过的 `.ts` 文件，把每条发现 `report` 出去，跑一遍测试，失败就派一个修复员，最后让撰写人汇总成 Markdown 交付物。

```ts
interface Finding {
  /** 工作区相对路径 */
  file: string;
  /** @minimum 1 */
  line: number;
  severity: "high" | "medium" | "low";
  /** @maxLength 200 */
  summary: string;
}

interface Review {
  findings: Finding[];
}

phase("逐个评审改动的文件");
const changed = (await git.changedFiles()).filter((p) => p.endsWith(".ts"));
const reviews = await Promise.all(
  changed.map((path) =>
    agent(`评审员-${path}`).ask<Review>(`评审 ${path} 的改动，只报告有依据的问题。`),
  ),
);
const findings = reviews.flatMap((r) => r.findings);
for (const f of findings) report(f);

phase("确认测试仍然通过");
const test = await world.run("pnpm", ["test"]);
if (test.exitCode !== 0) {
  await agent("修复员").ask(`测试失败，修复它：\n${test.stdout.slice(-4000)}`);
}

phase("汇总并产出报告");
const summary = await agent("报告撰写人").ask(`把这些发现写成 Markdown：${JSON.stringify(findings)}`);
await artifact.markdown("report", summary, { title: "评审报告", primary: true });
return { findings, testsPassed: test.exitCode === 0 };
```

顶层 `await` 与末尾的 `return` 都合法，`interface` 前不能写 `declare`，也不能 `export`、`import`。这份脚本编译零诊断，产物清单是 `[{"id":"report","kind":"markdown"}]`。评审员的名字按文件拼出来，所以不会撞上下面的 9006。

## 编译流水线

入口是 `analyzeWorkflowScript`（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/analyze.ts:64`），注释里写明的顺序是 typecheck、collectSites、diagnostics、interpret、four projections（`README.md:32`）。schema 合成与 lowering 不在这条函数里，而是挂在同一个 `ts.Program` 上的另外两趟：

```mermaid
flowchart TD
  S["脚本文本"] --> W["包进 async function __workflowScript__"]
  W --> H["虚拟宿主：脚本 + 门面 d.ts + 内嵌标准库"]
  H --> P["ts.Program"]
  P -->|"TS 诊断"| X["返回诊断，ok=false，不出图"]
  P --> T["collectSites 站点表"]
  T -->|"9001 门面逃逸"| X
  T --> R["编写期规则 9003～9009"]
  R -->|"除 9006 外的任一条"| X
  R --> I["interpret：taint 不动点，再一趟时序遍历，铸造 AnalysisCore"]
  I --> G["站点图、因果图、控制流图、交接图"]
  T --> SC["synthesizeAskSchemas：结果类型到 JSON Schema，9002"]
  T --> L["lowerWorkflow：擦类型、插站点 ID"]
```

### 虚拟宿主：为什么不读磁盘

脚本先被包进一个 async 函数，前缀恰好一行（`compile.ts:31`），这正是运行时执行函数体的形状，所以顶层 `await` 与末尾 `return` 都合法；诊断的行号再减去这一行，换回作者脚本的坐标（`compile.ts:105`）。编译选项（`compile.ts:35`）：

```ts
const COMPILER_OPTIONS: ts.CompilerOptions = {
  allowJs: false,
  lib: ["lib.es2022.d.ts"],
  module: ts.ModuleKind.ESNext,
  moduleResolution: ts.ModuleResolutionKind.Bundler,
  noEmit: true,
  skipLibCheck: true,
  // `strict` on, `noUncheckedIndexedAccess` deliberately OFF. Models write TS as
  // if the flag were off — training corpora almost universally have it off — so
  // with it on, TS2532/TS18048 on `items[i]` was the single largest source of
  // compile failures (96% of the undefined-family diagnostics in local runs),
  // almost all on indexes whose bounds the surrounding logic already proved.
  // `strictNullChecks` stays: `.find()` / `match()` / optional properties are
  // real hazards and their messages name the cause.
  strict: true,
  target: ts.ScriptTarget.ES2022,
  // No ambient @types: the script sees ES2022 + the facade and nothing else.
  // `process`, `fetch`, `require` etc. fail typechecking — the purity contract
  // starts at compile time.
  types: [],
};
```

`types: []` 让 `process`、`fetch`、`require` 在编译期就报“找不到名字”，纯度从这里开始。编译器宿主完全虚拟：脚本、门面和 TypeScript 标准库全部从一个内存 `Map` 里取，`writeFile` 直接抛错（`compile.ts:135`）。不读磁盘是因为 CLI 最终打成 SEA 单文件，运行时没有 `node_modules`，照常用 `ts.createCompilerHost` 就读不到 `lib.es2022.d.ts`（`apps/zcode-cli/packages/dynamic-workflow/scripts/generate-libs.mjs:1`）。构建脚本从 `lib.es2022.d.ts` 出发，顺着 `/// <reference lib>` 把整条引用链收进 `src/compiler/libs.generated.ts`（`generate-libs.mjs:40`），装的 `typescript` 版本没变就直接退出（`generate-libs.mjs:32`）。笔者用 TypeScript 5.9.3 跑，收进了 57 个文件。README 点明了这样做的另一个好处：开发与打包后的行为完全一致，缺了哪个 lib 在包测试里就会暴露（`README.md:133`）。

诊断文本也为模型的自我修复改写过。脚本顶层写 `declare` 或 `export`，TypeScript 只报一句 TS1184“Modifiers cannot appear here.”，不说是哪个修饰符；编译器按出错的那段文字换成能照做的说明（`compile.ts:166`）。

### 站点表与编写期诊断

`collectSites` 在包装函数体上走一遍 AST，所有门面调用都经 checker 解析到声明再归类，不看拼写（`sites.ts:36`）。门面成员的身份由“声明容器 + 成员名”决定：`git.log` 和顶层的 `log()` 同名，只按名字判断，要么给每条进度消息都铸一个站点，要么把 `git.log` 的站点丢掉（`apps/zcode-cli/packages/dynamic-workflow/src/facade/registry.ts:13`）。`phase()` 被收集却不算站点：没有 ID、不占任何计数器，加减阶段标记不会挪动别的站点编号（`sites.ts:149`）。

站点表之后是一串诊断。编号是包内自定义的，刻意避开 TypeScript 自己的码段：

| 码 | 出处 | 含义 |
| --- | --- | --- |
| 9001 | `apps/zcode-cli/packages/dynamic-workflow/src/analysis/facade-misuse.ts:20` | 门面函数只能直接调用：`const spawn = agent`、解构出 `ask`、把 `Agent` 转成结构兼容的本地类型，都会让调用失去站点 |
| 9002 | `apps/zcode-cli/packages/dynamic-workflow/src/schema/types.ts:78` | 结果类型或 `report` 的实参不能序列化成 JSON（`any`、`Date`、函数、类实例、`Promise`……） |
| 9003 | `apps/zcode-cli/packages/dynamic-workflow/src/analysis/world-run.ts:18` | `world.run` 的第一个实参不是编译期字面量 |
| 9004 | `apps/zcode-cli/packages/dynamic-workflow/src/analysis/phases.ts:19` | `phase()` 的名字不是字面量、为空，或不是独立语句 |
| 9005 | `apps/zcode-cli/packages/dynamic-workflow/src/analysis/actor-names.ts:43` | 两个 `agent()` 用了同一个字面量名字 |
| 9006 | `actor-names.ts:54` | 在 fan-out 体内用固定名字建 actor，元素多于一个时运行期必然重名 |
| 9007 | `apps/zcode-cli/packages/dynamic-workflow/src/analysis/artifacts.ts:37` | 产物 id 不是字面量、为空、超长、含非法字符，同一 id 跨了两种成员，或 `report` 的标签指错 |
| 9008 | `artifacts.ts:48` | 看板声明写在循环、回调或条件分支里 |
| 9009 | `artifacts.ts:55` | 两个不同的 id 都写了字面量 `primary: true` |

9001 的检查分三趟：引用扫描、对“有门面签名却没登记成站点”的直接调用兜底，以及在类型转换边界上抓“把门面值伪装成本地类型”（`facade-misuse.ts:48`）。理由写在规则说明里：日志键、回放与界面都以静态站点 ID 为坐标，一个只能经逃逸出去的函数值才到达的门面调用没有站点，放宽处理表示不了它，悄悄丢掉又不可靠，所以在编译期拒绝（`facade-misuse.ts:39`）。9003、9004、9007 同一个道理：命令集、阶段名和产物清单要在确认窗里给用户看，运行期才成形的值没有可展示的东西（`world-run.ts:4`）。

有几处值得留意：

- 9006 是唯一不扣下图的诊断，`ok` 照样为假。它说的是“跑起来会重名”，形状本身可以分析，扣下图反而让作者看不到是哪个 fan-out 出的问题（`analyze.ts:92`）。9005 与 9006 分成两个码，是因为后者理论上会误报（集合可能只有一个元素），读端需要区分，而仓库禁止按错误文本分流（`actor-names.ts:45`）。
- 9002 不在 `analyzeWorkflowScript` 里，它由 `synthesizeAskSchemas` 产出（`apps/zcode-cli/packages/dynamic-workflow/src/schema/synthesize.ts:74`）。`CreateWorkflow` 的处理函数只调用前者（`apps/zcode-cli/packages/core/src/tool/handlers/workflow-script-analysis.ts:20`），于是像 `ask<{ at: Date }>` 这样的脚本会通过分析、弹出确认窗，直到提交时由 run service 的 `compileOnce` 抛错（`apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-submit.ts:540`）。这一段留到第三篇。
- 反过来，`compileOnce` 只复验类型检查、schema（9002）与 `world.run` 字面量（9003）三项，其余编写期诊断全靠处理函数那一趟（`dynamic-workflow-run-submit.ts:524`）；所以重名诊断不是提交路径上的强制门，具名唯一性的权威始终是引擎运行期的查重（`actor-names.ts:30`）。

笔者用上面那份编译器跑了几个错例，信息都是给模型读的整句，例如 9003：

> world.run's first argument must be a compile-time string literal ("lean" or a no-substitution template): the script's command set is shown to the user at confirmation and only those commands are executable.

### 解释器：taint、不动点与时序遍历

诊断干净后，`interpret` 对脚本做一次“融合”解释，产出 `AnalysisCore`（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/interpret.ts:21`）。分两半。

**数据流的一半是 taint 分析**。抽象值带三样东西：可能流入它的站点标签（`occs`）、静态可知的字段（`fields`，让 `x.f`、`[a, b]` 保持字段敏感）、它可能是哪些脚本函数（`fns`，让高阶代码的调用图保持完整）；所有合并都是只增不减的并集（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/domain.ts:8`）。分析是 gen-only 的 may-flow：没有消毒、没有强更新，事实只会增加，所以用一个全局环境、忽略语句先后，就能同时覆盖所有执行顺序；循环里被重新赋值的变量也自然并进同一个绑定（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/taint.ts:49`）。整段脚本反复迭代到不动点（`taint.ts:85`）：

```ts
  converge(): void {
    let iterations = 0;
    do {
      this.s.changed = false;
      iterations += 1;
      if (iterations > ITERATION_CAP) {
        throw new Error(`taint analysis failed to converge after ${ITERATION_CAP} iterations`);
      }
      this.iterate();
      this.s.propagateReachability();
      this.s.recomputePromotion();
    } while (this.s.changed);
  }
```

上限 `ITERATION_CAP` 是 100（`taint.ts:61`）。单调加有限格保证收敛，撞上限是 bug，抛出来而不是悄悄截断。`xs.map(fn)`、`for...of` 这些迭代候选，只有当循环体真的够到门面站点时才被“提升”成 fan-out 节点（`sites.ts:208`）；哪些库函数会逐元素调用回调，集中登记在一张表里（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/callbacks.ts:61`）。

**时序的一半是一次遍历**。不动点收敛之后，按求值顺序走一遍脚本体，记下每一步何时发出、每个 `await` 屏障落在哪里、外面包着哪个语法区域（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/causality-order.ts:34`）。`await` 的解析也分两半：求值被 `await` 的表达式时发出的步骤，确定在此处结算；被 `await` 的值身上带着的标签，靠上一半的 taint 结果当“神谕”来回答，用来解决存进变量、传进 helper 或汇合后的 promise。两半都为空时屏障放宽到所有在飞的步骤。放宽会多排序、低估并行，注释称之为“诚实的方向”，少排序才是绝不允许的（`causality-order.ts:53`）。函数体只在调用处内联展开，调用了谁同样读神谕，不从语法上猜；递归按调用图的自可达性预先算好，展开体包一个 `loop` 区域并切断重入（`causality-order.ts:60`）。

最后是“铸造”：一切需要源码偏移或 checker 的东西，都在这一刻消化成纯数据，比如站点落在哪个 fan-out 里、fan-out 的最终编号、产物类型的字符串（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/core.ts:13`）。从此四种图都是 core 上的纯投影，投影模块不许 import `typescript`，这也是 `projections.ts` 能打给浏览器用的前提（`apps/zcode-cli/packages/dynamic-workflow/src/projections.ts:1`）。

### 四种投影

| 投影 | 回答的问题 | 节点与边 |
| --- | --- | --- |
| 站点图 `projectSiteGraph` | 谁的输出可能流进谁 | 节点是 ask、world-read、join、fan-out 站点加虚拟的 source、sink；`data` 边表示“A 的输出可能喂给 B”，`context` 边连起共用同一 actor 的 ask（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/types.ts:84`） |
| 因果图 `projectCausalityGraph` | 什么必须在什么之前 | 步骤（ask 与世界读取）的偏序，actor 是车道而不是节点；边只画一种箭头“runs after”，内部记着原因 `data`、`control`、`fifo`、`seq`、`carry`（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/causality-graph.ts:26`） |
| 控制流图 `projectControlFlow` | 执行下一步可能去哪 | 节点是“出现”而不是站点（被调用两次的 helper 出两个节点），边有 `next`、`branch`、`loop`、`exit`、`fork`、`join`、`jump`、`throw`、`may-throw`，再按阶段取商（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/flow-graph.ts:18`） |
| 交接图 `projectHandoffGraph` | 每个阶段谁参与、谁交给谁 | 每阶段每条车道一张卡，fan-out 家族在基数已知且不超过 8 时逐个出卡（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/handoff-graph.ts:49`），卡之间是因果图的阶段内边取商后再归约 |

确认窗里的图由因果图、控制流图和交接图三份拼成（`apps/zcode-cli/packages/core/src/tool/handlers/workflow-analysis-display.ts:5`）。上面那份评审脚本的因果图，文本形式里最能说明问题的几行是（节选）：

```text
step world-read#1 world-read "git-changed-files" @16:28 lane=workspace phase=phase#1 region=seq#1 always
step ask#1 ask "ask" label-head="评审员-" @19:26 lane=actor#1 phase=phase#1 region=fanout#1 maybe stack
step world-read#2 world-read "run pnpm" @26:26 lane=workspace phase=phase#2 region=seq#1 always
step ask#2 ask "修复员" @28:22 lane=actor#2 phase=phase#2 region=branch#1 maybe
step ask#3 ask "报告撰写人" @32:38 lane=actor#3 phase=phase#3 region=seq#1 always
edge world-read#1 -> ask#1 data maybe exact
edge ask#1 -> world-read#2 seq maybe
edge ask#1 -> ask#3 data maybe exact
edge world-read#2 -> ask#2 control maybe
edge ask#2 -> ask#3 seq maybe
```

评审员在 fan-out 体里，集合可能为空，不是每次都会发出，所以是 `maybe`；`stack` 表示各元素的实例同时存在，界面上叠成一摞卡片（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/causality-graph-types.ts:57`）；修复员只在分支里发生，它和测试之间是 `control` 边；撰写人的指令里插了 `findings`，所以和评审员之间是 `data` 边。评审员的名字是模板拼的，图上只拿得到前缀“评审员-”：把模板折成具体名字需要展开 `map`，而这正是图要拒绝的（`apps/zcode-cli/packages/dynamic-workflow/src/analysis/types.ts:19`）。

## 结果类型怎样变成 JSON Schema

`ask<T>` 带了类型实参、且 T 解析后不是原始 `string` 的，才算“有类型”的 ask；`ask()` 与 `ask<string>()` 的结果就是最终回复文本，不出 schema（`synthesize.ts:32`）。发射器由 checker 的结构化视图驱动，泛型、别名、映射类型都已被展平（`apps/zcode-cli/packages/dynamic-workflow/src/schema/emit.ts:6`）：

- `any` 拒绝并建议改用 `unknown`，`unknown` 发射成空 schema `{}`；`undefined` 只允许出现在可选属性上；`bigint`、`symbol`、函数、类实例、thenable 与 `Date`、`Map`、`Error`、各种 TypedArray 等内建对象一律拒绝（`emit.ts:106`、`emit.ts:34`）。
- 全是字面量的联合发射成 `enum`，否则是 `anyOf`；成员超过 100 个按“病态宽联合”拒绝，真实场景多是模板字面量类型展开出的笛卡尔积（`apps/zcode-cli/packages/dynamic-workflow/src/schema/types.ts:80`）。
- 没有字符串索引签名的对象一律 `additionalProperties: false`。注释坦白这不忠于 TypeScript 的结构类型，理由是模型多给键几乎总是误解，报出来就是一条清楚的修复提示（`emit.ts:252`）。
- 递归类型只在真的自引用时才提升进 `$defs` 用 `$ref` 指过去，其余具名类型原地内联（`emit.ts:10`）。

属性上的 JSDoc 会被收集：正文成为 `description`，`@minimum`、`@maximum`、`@exclusiveMinimum`、`@exclusiveMaximum`、`@minLength`、`@maxLength`、`@pattern`、`@format`、`@minItems`、`@maxItems`、`@default` 变成对应的约束，其余标签静默忽略（`apps/zcode-cli/packages/dynamic-workflow/src/schema/jsdoc.ts:4`）。示意脚本里的 `Finding` 合成出来是这样（节选，紧凑排版）：

```json
{
  "type": "object",
  "properties": {
    "file": { "type": "string", "description": "工作区相对路径" },
    "line": { "type": "number", "minimum": 1 },
    "severity": { "enum": ["high", "medium", "low"] },
    "summary": { "type": "string", "maxLength": 200 }
  },
  "required": ["file", "line", "severity", "summary"],
  "additionalProperties": false
}
```

配套的校验器只认这个子集，每条违规是“路径、期望、实得”三元组，格式化成一行 `<path>: expected <expected>, got <got>`，直接放进给模型的修复回合（`apps/zcode-cli/packages/dynamic-workflow/src/schema/validate.ts:18`）。引擎只依赖一个注入的 `ValidateFn`，不 import 校验器本身，repair 的次数与流程在下一篇。

schema 还决定子会话拿到哪种 `submit_result` 工具。编译期按站点图把每个 ask 归到可能的 actor 上：一个 actor 能收到的 typed ask 全部同一个 schema，就是 `mono`，工具声明直接写成那个 schema，对这个 actor 整个生命周期不变，不打掉提示词缓存；schema 不止一种是 `generic`；全是无类型 ask 就是 `untyped`，不注册这个工具。只要有一个 ask 的 receiver 没解析出来，所有 actor 一律退回 `generic`（`apps/zcode-cli/packages/dynamic-workflow/src/schema/actor-submit-profiles.ts:10`、`actor-submit-profiles.ts:33`）。

## lowering：擦掉类型，插上站点 ID

最后一步把分析干净的脚本变成沙箱能跑的 JavaScript，分两趟（`apps/zcode-cli/packages/dynamic-workflow/src/lowering/lower.ts:19`）。第一趟在原 AST 上做 transform，按站点表持有的节点身份命中每个门面调用，改写成 `__host.*`，绝不按名字重新识别；第二趟对打过桩的文本做 `transpileModule` 级的类型擦除（`lower.ts:263`）。示意脚本降级后的开头几行：

```js
__host.enterPhase("\u9010\u4E2A\u8BC4\u5BA1\u6539\u52A8\u7684\u6587\u4EF6");
const changed = (await __host.worldRead("world-read#1", "git-changed-files", [])).filter((p) => p.endsWith(".ts"));
const reviews = await Promise.all(changed.map((path) => __host.ask("ask#1", __host.createActor("actor#1", `评审员-${path}`), `评审 ${path} 的改动，只报告有依据的问题。`)));
const findings = reviews.flatMap((r) => r.findings);
for (const f of findings)
    __host.report("report#1", f);
```

第一行的阶段名是 printer 新造的字符串字面量，非 ASCII 字符被转义成了 `\u` 序列。对照可以看出几条规则：`agent(...)` 变成 `__host.createActor(站点, ...)`，`x.ask<T>(...)` 变成 `__host.ask(站点, x, ...)` 并丢掉类型实参，世界读取的实参原样按位置打包成数组，`phase` 变成 `__host.enterPhase`，`Promise.all` 与 fan-out 是沙箱里的普通 promise，原样保留（`lower.ts:24`）。`world.run` 在这里也是一次 `worldRead`，op 是 `run`；到了日志里它单列一种节点，这是下一篇的事。

几个细节：

- 可选链上的 ask（`maybe?.ask(x)`）不能直接改写，否则 receiver 为空时会带着 `undefined` 调进引擎、让整个 run 以 `UnknownActor` 失败。改写成一个判空三目，非标识符的 receiver 经临时变量只求值一次，和 tsc 自己降级可选链的做法一样（`lower.ts:184`）。
- 门面里唯一的“值”`args` 按标识符改写成 `__host.args`，而不是往函数体里注入一个 `const args`：用户若自己再声明一个 `args`，注入会变成运行期的重复声明错误（`lower.ts:125`）。
- 产物契约是一个 async 函数体，唯一的自由标识符是 `__host`，里面不含任何 schema，引擎按站点 ID 从编译产物里查（`lower.ts:45`）。printer 与 transpile 都是纯函数，同样的输入得到逐字节相同的输出（`lower.ts:59`）。

生产路径上，这一切由 run service 的 `compileOnce` 在一个 `ts.Program` 上一次做完：类型检查、站点表、schema、`world.run` 命令集、每个 actor 的 submit profile、lowering，再对作者原文算 sha256 作为 `scriptHash`（`dynamic-workflow-run-submit.ts:524`）。哈希算在原文而不是降级后的函数体上，因为恢复运行时比对的是原文。

下一篇：[动态工作流（二）：引擎、沙箱与日志回放](https://daiw.net/manual/zcode/dwf-engine)——降级后的脚本怎样在 vm 子进程里跑，AskScheduler 怎样派活、修复与记账，日志又怎样让中断的 run 从原处接着跑。
