This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/superpowers/plans/2026-08-29-deterministic-steps-auto-recipe.md
xcsandClaude Opus 5 80e9b01f84 docs(plan): R8 —— 全 auto 的步要能声明「没有 guide」,并进 Task 9
Task 7 的评审挖出来的:manifest.ts 的 guide 是 z.string().min(1)(必填非空),
而全 auto 的步按设计就没有 guide —— 引擎正是按 guide.trim() === '' 分半 auto 与全 auto。
照现在的 schema,第二份计划里四步全 auto 的活动会在装包那一刻被拒。
改成 superRefine:声明了 auto 才允许 guide 为空;没有 auto 的照旧拦
——「作者忘了写做法」是个老错,那道门要留着。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 04:42:54 +08:00

134 KiB

确定性步骤(auto 配方)平台机制 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 给 PlanStep 加一个声明式配方 auto,让活动的一步可以由引擎按声明确定性地做完(投影台账 / 发卡 / 一次结构化 AI 调用 / 提交放行 / 说一句话),不问模型。

Architecture: 配方是纯数据(装进来仍然只是数据,可静态审、可回放),不执行活动包代码(ADR-004)。求值分三层:表达式(纯函数)→ 数据源展开(纯函数)→ 执行器(拿端口)。引擎在每一拍拍首跑当前这一步的配方;发了卡就收拍,没发卡就重求 doneWhen 收步。有 guide 的老活动一个字不改。

Tech Stack: TypeScript(exactOptionalPropertyTypes)· zod(契约)· vitest · Hono(路由)· 现有 @sfeel 求值器 · 现有 ModelGateway(结构化出参)

Spec: docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md(本计划只实现该 spec 的 §5;§3/§4/§6/§7 归第二份计划)

Global Constraints

  • 五道门禁以退出码验,不许以 echo 验:pnpm gates(= gen:tiers → typecheck → lint → depcruise → api:check → test)。
  • 契约变更四步:depcruise 反查下游 → 同步对应模块的 CONTRACT.md → pnpm api:check --update && git add api-report → 破坏性变更走三步弃用。
  • 禁止清单:不许 any / as unknown as / @ts-ignore;domain 层零 IO;不删测试;不动门禁配置。
  • mock 只许出现在外界(邮件通道、外部系统替身);系统内部机制不得 mock —— 单测里用手写的假端口对象(普通对象字面量)是允许的,那是依赖注入不是 mock 框架。
  • 模块分层:internal/domain 只准纯函数(零 IO、零端口);internal/usecase 可拿端口;@mastra / 数据库只准出现在 internal/adapter 与 apps/api/src/app。
  • 别动稳定版:不要跑 ./stable.sh update,不要改库里的数据。稳定版与开发共用 jcai_wos 库。
  • 本计划不改任何现有活动包;E04 / I01 / W05 的行为必须逐字不变(回归靠 pnpm test)。
  • 提交信息用中文,句式照仓里现有的(feat(loop-engine): …),并在末尾附上:
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet

前置:先开一条 feature 分支

当前 HEAD 在 主支 mvp-foundation 上。这份计划有十三次提交,不许直接堆在主支上。

git switch -c feat/deterministic-steps

后面每一条「提交」都落在这条分支上;全部做完之后由主支发起合并(切回 mvp-foundation 再 merge 这条分支),不要反过来把主支合进 feature。


文件结构

文件 职责
packages/contracts/src/auto-recipe.ts(新) 配方的 zod schema 与类型 —— 唯一真源
packages/contracts/src/manifest.ts(改) planStepSchema 加可选 auto
packages/contracts/src/index.ts(改) 转出 auto-recipe
apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts(新) 表达式求值 + 地址取行(纯函数)
apps/api/src/modules/loop-engine/internal/domain/auto-source.ts(新) 数据源展开成行(纯函数,两层封顶)
apps/api/src/modules/loop-engine/internal/domain/live-step.ts(新) 「这一单此刻在办哪一步」(从 turn-runner 提纯出来的纯函数)
apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts(新) 配方执行器 —— 逐笔执行、刷读数、留痕
apps/api/src/modules/loop-engine/port.ts(改) 新增 AnalyzeGateway;CardGateway.decide 已够用
apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts(改) 两处插入配方段 + 卡答复即收步
apps/api/src/modules/loop-engine/CONTRACT.md(改) 记不变量
apps/api/src/modules/human-tool/internal/domain/card-rules.ts(改) 审核卡放宽到单选项
apps/api/src/modules/activity-registry/internal/domain/static-audit.ts(改) autoRecipeFindings
apps/api/src/modules/interaction/internal/domain/view-render.ts(改) data-options 下拉选项从台账现读
apps/api/src/app/wiring/engines.ts(改) 装配 AnalyzeGateway(走 ModelGateway)
apps/api/src/app/routes/desk.ts(改) 件上保存后 wake

Task 1: 配方契约

Files:

  • Create: packages/contracts/src/auto-recipe.ts
  • Create: packages/contracts/src/__tests__/auto-recipe.test.ts
  • Modify: packages/contracts/src/manifest.ts(planStepSchema 末尾加 auto)
  • Modify: packages/contracts/src/index.ts(加一行 export * from './auto-recipe.ts';)

Interfaces:

  • Consumes: 无

  • Produces: AutoExpr · AutoSource · AutoAction · AutoRecipe 四个类型;autoRecipeSchema;PlanStep.auto?: AutoRecipe

  • Step 1: 写失败的测试

// packages/contracts/src/__tests__/auto-recipe.test.ts
import { describe, expect, it } from 'vitest';
import { autoRecipeSchema } from '../auto-recipe.ts';

describe('auto 配方契约', () => {
  it('收下一份用全了五个动词的配方', () => {
    const parsed = autoRecipeSchema.safeParse({
      let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] } },
      do: [
        { kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: { in: 'originalInternalPn' } }] }, fields: { internal_pn: { col: 'pn' } } },
        { kind: 'analyze', model: 'deepseek-v4-flash', into: 'items', instruction: '逐条判四态', input: { from: { each: 'parts.type_param' }, fields: { name: { col: 'param_name' } } }, output: { type: 'object' } },
        { kind: 'card', as: 'execution', variant: 'data', title: '补数据', basis: [{ k: '原 PN', v: { in: 'originalInternalPn' } }], gateLedgers: ['parts.profile'] },
        { kind: 'submit', rationale: '结论已备好' },
        { kind: 'say', text: '这一步办完了' },
      ],
    });
    expect(parsed.success).toBe(true);
  });

  it('不认识的动词当场拒 —— 没实现的形状不许装进来', () => {
    const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'drop', into: 'a.b' }] });
    expect(parsed.success).toBe(false);
  });

  it('嵌套只许两层:第三层当场拒', () => {
    const parsed = autoRecipeSchema.safeParse({
      do: [{ kind: 'project', into: 'a.b', from: { each: 'a.b', then: { each: 'c.d', then: { each: 'e.f' } } }, fields: {} }],
    });
    expect(parsed.success).toBe(false);
  });

  it('prune 只许挂在 project 上', () => {
    const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] });
    expect(parsed.success).toBe(false);
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts Expected: FAIL —— Failed to resolve import "../auto-recipe.ts"

  • Step 3: 写契约
// packages/contracts/src/auto-recipe.ts
/**
 * 确定性步骤的**声明式配方**(ADR-014 / spec 2026-08-29 §5.1)。
 *
 * 配方是**数据不是代码**:装进来仍然只是数据,可静态审、可回放、可在治理台上摆出
 * 「这一步系统会做什么」。执行第三方代码则相反 —— ADR-004 的立场是「不执行包代码即知包为何物」。
 *
 * **只声明真实现了的那几式。** 表达力有上限是刻意的:上限之外的不要硬塞,
 * 退回模型,或者把那一段判断从这一步里切出去。
 */
import { z } from 'zod';

/** 表达式 —— 递归,`z.lazy` 是唯一写法(zod 的自引用) */
export type AutoExpr =
  | string
  | number
  | boolean
  /** 入参 / 基座读数 / `let` 里算好的值 —— 三者同一个命名空间,`let` 优先 */
  | { readonly in: string }
  /** 本行的列 */
  | { readonly col: string }
  /** 外层那一行的列(两层嵌套时) */
  | { readonly outer: string }
  /** 拼接,一律按字符串 */
  | { readonly cat: readonly AutoExpr[] }
  /** 只留数字(铸号:`case:openedAt` → `20260829215103`) */
  | { readonly fmt: AutoExpr; readonly as: 'digits' }
  /** 查另一张表的一列;查不到给 `else`(缺省空串) */
  | {
      readonly lookup: string;
      readonly by: Readonly<Record<string, AutoExpr>>;
      readonly take: string;
      readonly else?: AutoExpr;
    }
  /** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */
  | { readonly when: string; readonly then: AutoExpr; readonly else: AutoExpr };

export const autoExprSchema: z.ZodType<AutoExpr> = z.lazy(() =>
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
    z.object({ in: z.string().min(1) }).strict(),
    z.object({ col: z.string().min(1) }).strict(),
    z.object({ outer: z.string().min(1) }).strict(),
    z.object({ cat: z.array(autoExprSchema).min(1) }).strict(),
    z.object({ fmt: autoExprSchema, as: z.literal('digits') }).strict(),
    z
      .object({
        lookup: z.string().min(1),
        by: z.record(z.string(), autoExprSchema),
        take: z.string().min(1),
        else: autoExprSchema.optional(),
      })
      .strict(),
    z.object({ when: z.string().min(1), then: autoExprSchema, else: autoExprSchema }).strict(),
  ]),
);

/**
 * 数据源 —— 这一笔要对着几行办。
 *
 * 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)·
 * `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
 * **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。
 */
export type AutoSource =
  | { readonly once: true }
  | { readonly rows: readonly Readonly<Record<string, AutoExpr>>[] }
  | {
      readonly each: string;
      readonly where?: Readonly<Record<string, AutoExpr>>;
      /** 第二层,**到此为止** —— 三层以上就别 auto 了 */
      readonly then?: { readonly each: string; readonly where?: Readonly<Record<string, AutoExpr>> };
    };

export const autoSourceSchema: z.ZodType<AutoSource> = z.union([
  z.object({ once: z.literal(true) }).strict(),
  z.object({ rows: z.array(z.record(z.string(), autoExprSchema)).min(1) }).strict(),
  z
    .object({
      each: z.string().min(1),
      where: z.record(z.string(), autoExprSchema).optional(),
      then: z
        .object({ each: z.string().min(1), where: z.record(z.string(), autoExprSchema).optional() })
        .strict()
        .optional(),
    })
    .strict(),
]);

/** 五个动词 —— `when` 为假就跳过这一笔 */
export type AutoAction =
  | {
      readonly kind: 'project';
      readonly when?: string;
      /** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
      readonly into: string;
      /** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */
      readonly prune?: boolean;
      readonly from: AutoSource;
      readonly fields: Readonly<Record<string, AutoExpr>>;
    }
  | {
      readonly kind: 'analyze';
      readonly when?: string;
      /** 活动声明过的模型名 */
      readonly model: string;
      /** 出参落进拍内作用域 `result:<into>`,供后面的 project 用 `each`/`lookup` 消费 */
      readonly into: string;
      /** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */
      readonly instruction: string;
      /** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */
      readonly input: { readonly from: AutoSource; readonly fields: Readonly<Record<string, AutoExpr>> };
      /** 出参 JSON Schema —— 顶层必须是 object */
      readonly output: Readonly<Record<string, unknown>>;
    }
  | {
      readonly kind: 'card';
      readonly when?: string;
      readonly as: 'execution' | 'decide';
      readonly variant?: 'data' | 'review';
      readonly title: AutoExpr;
      /** 决策卡问的那句话 */
      readonly ask?: AutoExpr;
      /** 作业指导书 —— 一条一句,静态文本 */
      readonly guide?: readonly string[];
      readonly standard?: AutoExpr;
      /** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */
      readonly basis?: readonly { readonly k: string; readonly v: AutoExpr }[];
      /** 这张卡的提交闸盯哪几张表 */
      readonly gateLedgers?: readonly string[];
      /** 决策卡的选项;`variant:'review'` 时允许只有一个 */
      readonly options?: readonly { readonly value: string; readonly label: string; readonly detail?: string }[];
    }
  | { readonly kind: 'submit'; readonly when?: string; readonly rationale: AutoExpr }
  | { readonly kind: 'say'; readonly when?: string; readonly text: AutoExpr };

export const autoActionSchema: z.ZodType<AutoAction> = z.discriminatedUnion('kind', [
  z
    .object({
      kind: z.literal('project'),
      when: z.string().min(1).optional(),
      into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'),
      prune: z.boolean().optional(),
      from: autoSourceSchema,
      fields: z.record(z.string(), autoExprSchema),
    })
    .strict(),
  z
    .object({
      kind: z.literal('analyze'),
      when: z.string().min(1).optional(),
      model: z.string().min(1),
      into: z.string().min(1),
      instruction: z.string().min(1),
      input: z.object({ from: autoSourceSchema, fields: z.record(z.string(), autoExprSchema) }).strict(),
      output: z.record(z.string(), z.unknown()),
    })
    .strict(),
  z
    .object({
      kind: z.literal('card'),
      when: z.string().min(1).optional(),
      as: z.enum(['execution', 'decide']),
      variant: z.enum(['data', 'review']).optional(),
      title: autoExprSchema,
      ask: autoExprSchema.optional(),
      guide: z.array(z.string().min(1)).optional(),
      standard: autoExprSchema.optional(),
      basis: z.array(z.object({ k: z.string().min(1), v: autoExprSchema }).strict()).optional(),
      gateLedgers: z.array(z.string().min(1)).optional(),
      options: z
        .array(z.object({ value: z.string().min(1), label: z.string().min(1), detail: z.string().optional() }).strict())
        .optional(),
    })
    .strict(),
  z.object({ kind: z.literal('submit'), when: z.string().min(1).optional(), rationale: autoExprSchema }).strict(),
  z.object({ kind: z.literal('say'), when: z.string().min(1).optional(), text: autoExprSchema }).strict(),
]);

export type AutoRecipe = {
  /** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */
  readonly let?: Readonly<Record<string, AutoExpr>>;
  /** 按序执行 */
  readonly do: readonly AutoAction[];
};

export const autoRecipeSchema: z.ZodType<AutoRecipe> = z
  .object({
    let: z.record(z.string(), autoExprSchema).optional(),
    do: z.array(autoActionSchema).min(1),
  })
  .strict();
  • Step 4: 挂到 PlanStep 上

在 packages/contracts/src/manifest.ts 里,planStepSchema 的 humanWrites 那一行之后、}); 之前插入:

  /**
   * **这一步系统按声明自己做**(2026-08-29,ADR-014 / spec §5)。
   *
   * 引擎在这一步的每一拍拍首跑配方:发了卡就收拍(零 token);没发卡且这一步没有 `guide`
   * 就重求 `doneWhen` 收步;有 `guide` 的照常问模型(半 auto)。
   * **老活动一个字不用改** —— 不写就是不写,不是缺件。
   */
  auto: autoRecipeSchema.optional(),

并在该文件顶部的 import 段加:

import { autoRecipeSchema } from './auto-recipe.ts';
  • Step 5: 转出契约

在 packages/contracts/src/index.ts 里加一行(按文件里既有的字母序位置放):

export * from './auto-recipe.ts';
  • Step 6: 跑测试确认通过

Run: pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts Expected: PASS(4 个用例全绿)

  • Step 7: 刷 api-report 并跑全量门禁

Run:

pnpm api:check --update && git add api-report
pnpm gates

Expected: 全绿。api:check 若报「契约变了没入库」,说明上一条没跑或没 git add。

  • Step 8: 提交
git add packages/contracts api-report
git commit -m "$(cat <<'EOF'
feat(contracts): auto 配方契约 —— 确定的活由引擎按声明做,五个动词四种地址

配方是数据不是代码(ADR-004:不执行包代码即知包为何物),装进来可静态审、可回放。
只声明真实现了的那几式;嵌套两层封顶,没实现的形状 zod 当场拒。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 2: 表达式求值器(纯函数)

Files:

  • Create: apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts
  • Create: apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts

Interfaces:

  • Consumes: Task 1 的 AutoExpr

  • Produces:

    • type Row = Readonly<Record<string, unknown>>
    • type ExprCtx = { scope: Readonly<Record<string, unknown>>; lets: Readonly<Record<string, unknown>>; row?: Row; outer?: Row; computed?: Row; condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean }
    • function rowsAt(scope: Readonly<Record<string, unknown>>, address: string): readonly Row[]
    • function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown
    • function evalText(expr: AutoExpr, ctx: ExprCtx): string
  • Step 1: 写失败的测试

// apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts
import { describe, expect, it } from 'vitest';
import { evalExpr, evalText, rowsAt } from '../internal/domain/auto-expr.ts';

/** 假的 S-FEEL:只认这份测试用得到的两句,够验证「条件走的是注入进来的求值器」 */
const condition = (expr: string, scope: Readonly<Record<string, unknown>>): boolean => {
  if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
  if (expr === 'n > 0') return Number(scope['n'] ?? 0) > 0;
  throw new Error(`没教过这句:${expr}`);
};
const base = { scope: {}, lets: {}, condition };

describe('rowsAt:地址取行', () => {
  const scope = {
    'parts.profile': [{ internal_pn: 'A', chip_type_id: 1 }],
    'mine:sub_eval.part': [{ internal_pn: 'B' }],
    pns: ['X', 'Y'],
    'result:items': [{ param_name: 'p' }],
  };
  it('域.对象 取任务视图整表', () => {
    expect(rowsAt(scope, 'parts.profile')).toEqual([{ internal_pn: 'A', chip_type_id: 1 }]);
  });
  it('mine: 只取本单草稿', () => {
    expect(rowsAt(scope, 'mine:sub_eval.part')).toEqual([{ internal_pn: 'B' }]);
  });
  it('in: 取入参数组;字符串元素归一成 { value }', () => {
    expect(rowsAt(scope, 'in:pns')).toEqual([{ value: 'X' }, { value: 'Y' }]);
  });
  it('result: 取本拍 AI 出参', () => {
    expect(rowsAt(scope, 'result:items')).toEqual([{ param_name: 'p' }]);
  });
  it('取不到就是空数组,不抛 —— 首拍很多表本来就还没有行', () => {
    expect(rowsAt(scope, 'nope.nope')).toEqual([]);
  });
});

describe('evalExpr:八种形状', () => {
  it('常量原样', () => {
    expect(evalExpr('W05', base)).toBe('W05');
    expect(evalExpr(7, base)).toBe(7);
    expect(evalExpr(true, base)).toBe(true);
  });
  it('{in} 先看 let,再看作用域', () => {
    const ctx = { ...base, scope: { pn: 'FROM-SCOPE', other: 'S' }, lets: { pn: 'FROM-LET' } };
    expect(evalExpr({ in: 'pn' }, ctx)).toBe('FROM-LET');
    expect(evalExpr({ in: 'other' }, ctx)).toBe('S');
  });
  it('{col} 取本行,{outer} 取外层那一行', () => {
    const ctx = { ...base, row: { a: 1 }, outer: { b: 2 } };
    expect(evalExpr({ col: 'a' }, ctx)).toBe(1);
    expect(evalExpr({ outer: 'b' }, ctx)).toBe(2);
  });
  it('取不到的列是空串,不是 undefined —— 空串写进草稿层等于「这一列没写」', () => {
    expect(evalExpr({ col: 'nope' }, { ...base, row: {} })).toBe('');
  });
  it('{cat} 按字符串拼', () => {
    expect(evalExpr({ cat: ['W05-', { in: 'd' }] }, { ...base, lets: { d: 20260829 } })).toBe('W05-20260829');
  });
  it('{fmt as digits} 只留数字', () => {
    expect(evalExpr({ fmt: { in: 't' }, as: 'digits' }, { ...base, lets: { t: '2026-08-29T21:51:03Z' } })).toBe('20260829215103');
  });
  it('{lookup} 查另一张表的一列', () => {
    const ctx = { ...base, scope: { 'parts.profile': [{ internal_pn: 'A', chip_type_id: 3 }] }, row: { pn: 'A' } };
    expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe(3);
  });
  it('{lookup} 查不到给 else,缺省空串', () => {
    const ctx = { ...base, scope: { 'parts.profile': [] }, row: { pn: 'A' } };
    expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe('');
    expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'x', else: 'absent' }, ctx)).toBe('absent');
  });
  it('{lookup} 比值按字符串 —— 台账里数字与文本混着存', () => {
    const ctx = { ...base, scope: { 'a.b': [{ k: 1, v: 'hit' }] }, row: { k: '1' } };
    expect(evalExpr({ lookup: 'a.b', by: { k: { col: 'k' } }, take: 'v' }, ctx)).toBe('hit');
  });
  it('{when} 走注入的 S-FEEL,已算出的同行字段进它的作用域', () => {
    const ctx = { ...base, computed: { found_ref: 'LM2596S-5.0' } };
    expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, ctx)).toBe('found');
    expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, { ...base, computed: { found_ref: '' } })).toBe('absent');
  });
  it('{when} 谓词算不出来时走 else,不把整拍拖垮', () => {
    expect(evalExpr({ when: '这句没教过', then: 'a', else: 'b' }, base)).toBe('b');
  });
});

describe('evalText:一律出字符串', () => {
  it('数字与布尔照实转', () => {
    expect(evalText(3, base)).toBe('3');
    expect(evalText(true, base)).toBe('true');
  });
  it('空与 undefined 都是空串', () => {
    expect(evalText({ col: 'nope' }, { ...base, row: {} })).toBe('');
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts Expected: FAIL —— Failed to resolve import "../internal/domain/auto-expr.ts"

  • Step 3: 写实现
// apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts
/**
 * 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。
 *
 * S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。
 * **不发明第二套求值** 是 ADR-014 的一条硬约束。
 */
import type { AutoExpr } from '@contracts';

export type Row = Readonly<Record<string, unknown>>;

export type ExprCtx = {
  /** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */
  readonly scope: Readonly<Record<string, unknown>>;
  /** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */
  readonly lets: Readonly<Record<string, unknown>>;
  readonly row?: Row;
  readonly outer?: Row;
  /** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */
  readonly computed?: Row;
  readonly condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean;
};

/**
 * 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。
 *
 * **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。
 */
export function rowsAt(scope: Readonly<Record<string, unknown>>, address: string): readonly Row[] {
  const raw = address.startsWith('in:') ? scope[address.slice('in:'.length)] : scope[address];
  if (!Array.isArray(raw)) return [];
  return raw.map((item) =>
    typeof item === 'object' && item !== null ? (item as Row) : ({ value: item } satisfies Row),
  );
}

/** 一个值参与比较时的归一:台账里数字与文本混着存,一律按字符串比 */
function asKey(value: unknown): string {
  return value === null || value === undefined ? '' : String(value);
}

export function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown {
  if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean') return expr;

  if ('in' in expr) {
    const fromLet = ctx.lets[expr.in];
    return fromLet !== undefined ? fromLet : (ctx.scope[expr.in] ?? '');
  }
  if ('col' in expr) return ctx.row?.[expr.col] ?? '';
  if ('outer' in expr) return ctx.outer?.[expr.outer] ?? '';
  if ('cat' in expr) return expr.cat.map((part) => evalText(part, ctx)).join('');
  if ('fmt' in expr) return evalText(expr.fmt, ctx).replace(/\D/g, '');

  if ('lookup' in expr) {
    const pairs = Object.entries(expr.by).map(([column, valueExpr]) => [column, asKey(evalExpr(valueExpr, ctx))] as const);
    const hit = rowsAt(ctx.scope, expr.lookup).find((row) => pairs.every(([column, want]) => asKey(row[column]) === want));
    if (hit === undefined) return expr.else === undefined ? '' : evalExpr(expr.else, ctx);
    return hit[expr.take] ?? (expr.else === undefined ? '' : evalExpr(expr.else, ctx));
  }

  /*
   * 条件走注入的 S-FEEL。谓词写坏了**不该拖垮整拍** —— 与 `doneWhen` 同一条处置:
   * 当作「没算出来」走 else。真写错了,静态审在装包时就该拦下(Task 9)。
   */
  const scope = { ...ctx.scope, ...ctx.lets, ...(ctx.outer ?? {}), ...(ctx.row ?? {}), ...(ctx.computed ?? {}) };
  let hit = false;
  try {
    hit = ctx.condition(expr.when, scope);
  } catch {
    hit = false;
  }
  return evalExpr(hit ? expr.then : expr.else, ctx);
}

/** 出字符串的那一路 —— 拼接、标题、留痕都要它 */
export function evalText(expr: AutoExpr, ctx: ExprCtx): string {
  const value = evalExpr(expr, ctx);
  return value === null || value === undefined ? '' : String(value);
}
  • Step 4: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts Expected: PASS(18 个用例)

  • Step 5: 提交
git add apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts
git commit -m "$(cat <<'EOF'
feat(loop-engine): 配方表达式求值 —— 八种形状,S-FEEL 仍由外面注入

地址四种、取不到一律空数组(首拍很多表本来就没行,抛了等于开局交人);
比值一律按字符串(台账里数字与文本混着存);谓词算不出来走 else,不拖垮整拍。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 3: 数据源展开(纯函数)

Files:

  • Create: apps/api/src/modules/loop-engine/internal/domain/auto-source.ts
  • Create: apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts

Interfaces:

  • Consumes: Task 1 的 AutoSource;Task 2 的 ExprCtx / Row / evalExpr / rowsAt

  • Produces: type ExpandedRow = { readonly row: Row; readonly outer?: Row };function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[]

  • Step 1: 写失败的测试

// apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts
import { describe, expect, it } from 'vitest';
import { expandSource } from '../internal/domain/auto-source.ts';

const condition = (): boolean => false;
const ctxWith = (scope: Record<string, unknown>) => ({ scope, lets: {}, condition });

describe('expandSource', () => {
  it('once:恰一行,行是空的', () => {
    expect(expandSource({ once: true }, ctxWith({}))).toEqual([{ row: {} }]);
  });

  it('rows:字面几行,每格现算', () => {
    const out = expandSource(
      { rows: [{ pn: { in: 'a' }, role: 'original' }, { pn: { in: 'b' }, role: 'candidate' }] },
      { scope: { a: 'LM2596S-5.0', b: 'XL2596S-5.0' }, lets: {}, condition },
    );
    expect(out).toEqual([
      { row: { pn: 'LM2596S-5.0', role: 'original' } },
      { row: { pn: 'XL2596S-5.0', role: 'candidate' } },
    ]);
  });

  it('each:按地址逐行', () => {
    const out = expandSource({ each: 'mine:sub_eval.part' }, ctxWith({ 'mine:sub_eval.part': [{ pn: 'A' }, { pn: 'B' }] }));
    expect(out.map((e) => e.row)).toEqual([{ pn: 'A' }, { pn: 'B' }]);
  });

  it('each + where:等值过滤,比值按字符串', () => {
    const out = expandSource(
      { each: 'mine:sub_eval.part', where: { exists_status: 'absent' } },
      ctxWith({ 'mine:sub_eval.part': [{ pn: 'A', exists_status: 'found' }, { pn: 'B', exists_status: 'absent' }] }),
    );
    expect(out.map((e) => e.row['pn'])).toEqual(['B']);
  });

  it('两层:里层的 where 认得出外层那一行', () => {
    const out = expandSource(
      { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
      ctxWith({
        'mine:sub_eval.part': [{ pn: 'A', chip_type_id: 1 }, { pn: 'B', chip_type_id: 2 }],
        'parts.type_param': [
          { type_id: 1, param_name: '输入电压范围' },
          { type_id: 1, param_name: '输出电流' },
          { type_id: 2, param_name: '压差' },
        ],
      }),
    );
    expect(out).toEqual([
      { outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输入电压范围' } },
      { outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输出电流' } },
      { outer: { pn: 'B', chip_type_id: 2 }, row: { type_id: 2, param_name: '压差' } },
    ]);
  });

  it('外层的 chip_type_id 还是空的时候,里层一行都不命中 —— 「保存即铺格」靠的就是这一条', () => {
    const out = expandSource(
      { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
      ctxWith({
        'mine:sub_eval.part': [{ pn: 'A', chip_type_id: '' }],
        'parts.type_param': [{ type_id: 1, param_name: '输入电压范围' }],
      }),
    );
    expect(out).toEqual([]);
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts Expected: FAIL —— Failed to resolve import "../internal/domain/auto-source.ts"

  • Step 3: 写实现
// apps/api/src/modules/loop-engine/internal/domain/auto-source.ts
/**
 * 配方数据源展开 —— **纯函数,零 IO**。
 *
 * 嵌套**两层封顶**:两层(某几颗料 × 该类型的参数字段)是已知业务的上限;
 * 超出就别 auto —— 退回模型,或者把那一段切出去。
 */
import type { AutoExpr, AutoSource } from '@contracts';
import { evalExpr, rowsAt, type ExprCtx, type Row } from './auto-expr.ts';

export type ExpandedRow = { readonly row: Row; readonly outer?: Row };

/** where 是等值过滤;比值按字符串(台账里数字与文本混着存) */
function matches(
  row: Row,
  where: Readonly<Record<string, AutoExpr>> | undefined,
  ctx: ExprCtx,
  outer?: Row,
): boolean {
  if (where === undefined) return true;
  return Object.entries(where).every(([column, valueExpr]) => {
    const want = evalExpr(valueExpr, { ...ctx, row, ...(outer === undefined ? {} : { outer }) });
    const got = row[column];
    return (got === null || got === undefined ? '' : String(got)) === (want === null || want === undefined ? '' : String(want));
  });
}

export function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[] {
  if ('once' in source) return [{ row: {} }];

  if ('rows' in source) {
    return source.rows.map((literal) => {
      const row: Record<string, unknown> = {};
      for (const [name, expr] of Object.entries(literal)) row[name] = evalExpr(expr, { ...ctx, row });
      return { row };
    });
  }

  const outers = rowsAt(ctx.scope, source.each).filter((row) => matches(row, source.where, ctx));
  if (source.then === undefined) return outers.map((row) => ({ row }));

  const inner = source.then;
  const out: ExpandedRow[] = [];
  for (const outer of outers) {
    for (const row of rowsAt(ctx.scope, inner.each)) {
      if (matches(row, inner.where, ctx, outer)) out.push({ row, outer });
    }
  }
  return out;
}
  • Step 4: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts Expected: PASS(6 个用例)

  • Step 5: 提交
git add apps/api/src/modules/loop-engine/internal/domain/auto-source.ts apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts
git commit -m "$(cat <<'EOF'
feat(loop-engine): 配方数据源展开 —— once / rows / each,嵌套两层封顶

里层的 where 认得出外层那一行:外层字段还是空的时候一行都不命中 ——
「人挑了类型、按了保存,参数格才出来」不是特判,是这一条的自然结果。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 4: 配方执行器 —— project / prune / say

Files:

  • Create: apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts
  • Create: apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts
  • Modify: apps/api/src/modules/loop-engine/port.ts(新增 AnalyzeGateway 类型;实现留到 Task 5)

Interfaces:

  • Consumes: Task 2 的 evalExpr / evalText / rowsAt;Task 3 的 expandSource;现有 TaskDataGateway(view / writable / draftWrite / draftDrop)、CardGateway、MemoryRecorder、Sfeel

  • Produces:

    • class AutoRecipeError extends Error(带 actionIndex)
    • type AnalyzeGateway = { run(input: { taskId: TaskId; model: string; instruction: string; payload: readonly unknown[]; outputSchema: Readonly<Record<string, unknown>>; label: string }): Promise<{ readonly object: Readonly<Record<string, unknown>> }> }
    • type AutoContext = { taskId: TaskId; stepId: string; ownerId: StaffId; humanCallCap: number; quiet: boolean; carded: boolean }
    • type AutoOutcome = { wrote: number; dropped: number; issuedCardId?: string; submit?: { rationale: string }; failedAt?: { index: number; message: string } }
    • function createAutoRunner(deps: AutoRunnerDeps): { run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome> }
  • Step 1: 先把 AnalyzeGateway 加进 port.ts

在 apps/api/src/modules/loop-engine/port.ts 末尾(ViewGateway 之后)加:

/**
 * **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。
 *
 * 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账;
 * 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。
 * **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。
 */
export type AnalyzeGateway = {
  run(input: {
    readonly taskId: TaskId;
    /** 活动声明过的模型名 */
    readonly model: string;
    readonly instruction: string;
    /** 系统组装好的入参 —— 一行一条 */
    readonly payload: readonly unknown[];
    readonly outputSchema: Readonly<Record<string, unknown>>;
    /** 归因:`<步名>#<第几笔>` */
    readonly label: string;
  }): Promise<{ readonly object: Readonly<Record<string, unknown>> }>;
};

并把 AnalyzeGateway 加进 TurnRunnerDeps(apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts 第 43 行起的那个类型)最后一行之前:

  readonly analyze: AnalyzeGateway;

以及在该文件顶部的 import 里补上 AnalyzeGateway(与 AgentPort 同一行 import)。

这一步会让两个地方少一个必填属性,两处都要在本任务里补上,否则 typecheck 当场红:

① 装配层占位(apps/api/src/app/wiring/engines.ts,Task 5 换成真实现):

analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) },

② 测试脚手架的桩(apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts, 在 const deps: TurnRunnerDeps = { 里、agent: { … } as never, 之后加一行):

    analyze: { run: async () => ({ object: {} }) } as never,

补完先跑一遍确认既有用例没被碰坏: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts

  • Step 2: 写失败的测试
// apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts
import { describe, expect, it } from 'vitest';
import type { AutoRecipe } from '@contracts';
import { taskIdSchema, staffIdSchema } from '@contracts';
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';

const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');

/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
function harness(seed: Record<string, unknown> = {}) {
  const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
  const notes: string[] = [];
  const view = (): Record<string, unknown> => {
    const mine: Record<string, unknown[]> = {};
    for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
    return { ...seed, ...mine };
  };
  const deps = {
    data: {
      view: () => Promise.resolve(view()),
      writable: () =>
        Promise.resolve([
          { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] },
          { domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
        ]),
      draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
        const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
        const before = JSON.stringify(drafts.get(id)?.patch ?? null);
        drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch });
        return Promise.resolve({ changed: before !== JSON.stringify(i.patch) });
      },
      draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
        const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
        return Promise.resolve({ dropped: drafts.delete(id) });
      },
    },
    cards: {
      issue: () => Promise.resolve({ id: 'card-1' }),
      decide: () => Promise.resolve({ id: 'card-2' }),
      entry: () => Promise.resolve({ id: 'card-3' }),
      ask: () => Promise.resolve({ id: 'card-4' }),
      unanswered: () => Promise.resolve([]),
    },
    memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
    analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
    sfeel: {
      condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
        if (expr === 'never') return false;
        if (expr === 'always') return true;
        if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
        throw new Error(`没教过这句:${expr}`);
      },
      expression: () => undefined,
      unaryTest: () => false,
    },
  };
  const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 假端口只实现用得到的那几个方法
  return { runner: createAutoRunner(deps as never), drafts, notes, ctx };
}

describe('配方执行器', () => {
  it('project once:写一行,主键与其余列分开', async () => {
    const { runner, drafts, ctx } = harness({ pn: 'LM2596S-5.0' });
    const recipe: AutoRecipe = {
      let: { evalRef: 'W05-1' },
      do: [
        {
          kind: 'project',
          into: 'sub_eval.part',
          from: { once: true },
          fields: { eval_ref: { in: 'evalRef' }, internal_pn: { in: 'pn' }, role: 'original' },
        },
      ],
    };
    const out = await runner.run(recipe, ctx);
    expect(out.wrote).toBe(1);
    expect([...drafts.values()][0]).toEqual({
      domain: 'sub_eval',
      object: 'part',
      key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' },
      patch: { role: 'original' },
    });
  });

  it('主键算不出来的那一行**先不写** —— 空主键写下去就是一行永远对不上的垃圾', async () => {
    const { runner, drafts, ctx } = harness({});
    const out = await runner.run(
      { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: { in: 'nope' }, role: 'x' } }] },
      ctx,
    );
    expect(out.wrote).toBe(0);
    expect(drafts.size).toBe(0);
  });

  it('when 为假就跳过这一笔', async () => {
    const { runner, drafts, ctx } = harness({});
    await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx);
    expect(drafts.size).toBe(0);
  });

  it('**每一笔之前刷读数**:第二笔 each 得看得见第一笔刚写下的行', async () => {
    const { runner, drafts, ctx } = harness({});
    await runner.run(
      {
        do: [
          { kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } },
          { kind: 'project', into: 'parts.parameter', from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, param_name: '输入电压范围', param_value: '' } },
        ],
      },
      ctx,
    );
    const params = [...drafts.values()].filter((d) => d.object === 'parameter');
    expect(params.map((p) => p.key['internal_pn']).sort()).toEqual(['A', 'B']);
  });

  it('值没变的写是空转:**不留痕、不计数**(配方不是模型,原地打转不必被看见)', async () => {
    const { runner, ctx } = harness({});
    const recipe: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] };
    await runner.run(recipe, ctx);
    const second = await runner.run(recipe, ctx);
    expect(second.wrote).toBe(0);
  });

  it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => {
    const { runner, drafts, ctx } = harness({});
    await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx);
    expect(drafts.size).toBe(2);
    const out = await runner.run(
      { do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
      ctx,
    );
    expect(out.dropped).toBe(1);
    expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
  });

  it('say:进工作日志,归系统', async () => {
    const { runner, notes, ctx } = harness({ n: 2 });
    await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx);
    expect(notes).toContain('已核对 2 颗料');
  });

  it('地址不在台账声明里 → 当场交人,不重试', async () => {
    const { runner, ctx } = harness({});
    const out = await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx);
    expect(out.failedAt?.index).toBe(0);
    expect(out.failedAt?.message).toContain('nope.nope');
  });
});
  • Step 3: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts Expected: FAIL —— Failed to resolve import "../internal/usecase/auto-runner.ts"

  • Step 4: 写执行器(本任务只实现 project / prune / say;analyze / card / submit 留占位抛错)
// apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts
/**
 * 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。
 *
 * 三条硬规矩:
 *   ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 ——
 *      推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。
 *   ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)——
 *      「写了等于没写」那条判据是拦模型空转的,配方不是模型;
 *      发卡先看 `carded`(worldTouched),发过就不再发。
 *   ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。
 *      退回模型就是让它去凑,那正是这套机制要消灭的东西。
 */
import type { AutoAction, AutoRecipe, StaffId, TaskId } from '@contracts';
import { evalExpr, evalText, rowsAt, type ExprCtx, type Row } from '../domain/auto-expr.ts';
import { expandSource } from '../domain/auto-source.ts';
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';

export class AutoRecipeError extends Error {
  constructor(
    readonly actionIndex: number,
    message: string,
  ) {
    super(message);
    this.name = 'AutoRecipeError';
  }
}

export type AutoRunnerDeps = {
  readonly data: TaskDataGateway;
  readonly cards: CardGateway;
  readonly memory: MemoryRecorder;
  readonly analyze: AnalyzeGateway;
  readonly sfeel: Sfeel;
};

export type AutoContext = {
  readonly taskId: TaskId;
  readonly stepId: string;
  readonly ownerId: StaffId;
  readonly humanCallCap: number;
  /** 有未答的卡在场:**只跑 project / say** —— 只算、只写,不发新卡、不 submit、不 analyze */
  readonly quiet: boolean;
  /** 这一步已经发过卡(worldTouched) */
  readonly carded: boolean;
};

export type AutoOutcome = {
  readonly wrote: number;
  readonly dropped: number;
  readonly issuedCardId?: string;
  readonly submit?: { readonly rationale: string };
  readonly failedAt?: { readonly index: number; readonly message: string };
};

const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|');

export function createAutoRunner(deps: AutoRunnerDeps): {
  run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome>;
} {
  return {
    async run(recipe, ctx) {
      const writable = await deps.data.writable(ctx.taskId);
      const results: Record<string, unknown[]> = {};
      let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results };
      let dirty = false;
      let wrote = 0;
      let dropped = 0;
      let issuedCardId: string | undefined;
      let submit: { rationale: string } | undefined;

      const condition = deps.sfeel.condition;
      const lets: Record<string, unknown> = {};
      for (const [name, expr] of Object.entries(recipe.let ?? {})) {
        lets[name] = evalExpr(expr, { scope, lets, condition });
      }

      async function runAction(
        action: AutoAction,
        index: number,
        base: ExprCtx,
        context: AutoContext,
      ): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> {
        if (action.kind === 'say') {
          await deps.memory.append({
            taskId: context.taskId,
            kind: 'result',
            title: evalText(action.text, base),
            detail: { auto: `${context.stepId}#${index + 1}` },
          });
          return { wrote: 0, dropped: 0 };
        }

        if (action.kind === 'project') {
          const [domain, object] = action.into.split('.');
          const decl = writable.find((w) => w.domain === domain && w.object === object);
          if (decl === undefined) {
            throw new AutoRecipeError(index, `台账地址不在本活动的声明里:${action.into}(写的授权面是台账声明)`);
          }
          const before = rowsAt(base.scope, `mine:${action.into}`);
          const kept = new Set<string>();
          let localWrote = 0;
          for (const { row, outer } of expandSource(action.from, base)) {
            const computed: Record<string, unknown> = {};
            for (const [name, expr] of Object.entries(action.fields)) {
              computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed });
            }
            const key: Record<string, string> = {};
            for (const column of decl.keys) key[column] = String(computed[column] ?? '');
            /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */
            if (decl.keys.some((column) => key[column] === '')) continue;
            const patch: Record<string, unknown> = {};
            for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
            const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch });
            if (changed) localWrote += 1;
            kept.add(keyText(key));
          }
          let localDropped = 0;
          if (action.prune === true) {
            for (const stale of before) {
              const key: Record<string, string> = {};
              for (const column of decl.keys) key[column] = String(stale[column] ?? '');
              if (kept.has(keyText(key))) continue;
              const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key });
              if (gone) localDropped += 1;
            }
          }
          if (localWrote > 0 || localDropped > 0) {
            await deps.memory.append({
              taskId: context.taskId,
              kind: 'result',
              title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
              detail: { auto: `${context.stepId}#${index + 1}`, internal: true, ledger: action.into },
            });
          }
          return { wrote: localWrote, dropped: localDropped };
        }

        throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`);
      }

      for (const [index, action] of recipe.do.entries()) {
        try {
          if (dirty) {
            scope = { ...(await deps.data.view(ctx.taskId)), ...results };
            dirty = false;
          }
          const base: ExprCtx = { scope, lets, condition };
          if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
          /* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
          if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue;

          const done = await runAction(action, index, base, ctx);
          wrote += done.wrote;
          dropped += done.dropped;
          if (done.wrote > 0 || done.dropped > 0) dirty = true;
          if (done.cardId !== undefined) issuedCardId = done.cardId;
          if (done.submit !== undefined) submit = done.submit;
          if (done.stop) break;
        } catch (err) {
          const message = err instanceof Error ? err.message : String(err);
          await deps.memory.append({
            taskId: ctx.taskId,
            kind: 'result',
            title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
            detail: { auto: `${ctx.stepId}#${index + 1}`, internal: false },
          });
          return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
        }
      }
      return {
        wrote,
        dropped,
        ...(issuedCardId === undefined ? {} : { issuedCardId }),
        ...(submit === undefined ? {} : { submit }),
      };
    },
  };
}
  • Step 5: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts Expected: PASS(8 个用例)

  • Step 6: 跑全量,确认没碰坏别人

Run: pnpm exec vitest run Expected: 全绿(现有 1600+ 用例)

  • Step 7: 提交
git add apps/api/src/modules/loop-engine
git commit -m "$(cat <<'EOF'
feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数

三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0);
值没变的写静默跳过(配方不是模型,原地打转不必被看见);
出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。
主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 5: analyze 动词 —— 一次结构化 AI 调用

Files:

  • Modify: apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts(补 analyze 分支)
  • Modify: apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts(补 3 个用例)
  • Create: apps/api/src/app/wiring/analyze-gateway.ts
  • Create: apps/api/src/app/__tests__/analyze-gateway.test.ts
  • Modify: apps/api/src/app/wiring/engines.ts(把 Task 4 的占位换成真实现)

Interfaces:

  • Consumes: Task 4 的 AnalyzeGateway 类型;现有 ModelGateway.generate(call: ModelCall): Promise<ModelResult>(ModelCall 有 model / system / prompt / temperature / reasoning / outputSchema / taskId / label;ModelResult.object 是 schema 给定时的解析结果)

  • Produces: function createAnalyzeGateway(llm: ModelGateway): AnalyzeGateway

  • Step 1: 先把 harness() 改成可以换端口

analyze 的用例要能换掉那个端口,所以先改 Task 4 写的 harness():签名加第二个参数, deps.analyze 那一行改成读它。

把 auto-runner.test.ts 里这一行:

function harness(seed: Record<string, unknown> = {}) {

改成:

type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record<string, unknown> }> };

function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {

并把 const deps = { ... } 里的这一行:

    analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },

改成:

    analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
  • Step 2: 写 analyze 的三个失败用例

在 auto-runner.test.ts 末尾追加:

describe('analyze 动词', () => {
  it('入参由声明组装,出参落进 result: 供后一笔 each 消费', async () => {
    let seen: readonly unknown[] = [];
    const { runner, drafts, ctx } = harness(
      {
        'parts.type_param': [
          { type_id: 1, param_name: '输入电压范围', constraint_type: 'hard' },
          { type_id: 1, param_name: '输出电流', constraint_type: 'soft' },
        ],
        typeId: 1,
      },
      {
        analyze: {
          run: (i) => {
            seen = i.payload;
            return Promise.resolve({
              object: {
                items: [
                  { param_name: '输入电压范围', difference_status: 'match' },
                  { param_name: '输出电流', difference_status: 'unknown' },
                ],
              },
            });
          },
        },
      },
    );
    await runner.run(
      {
        do: [
          {
            kind: 'analyze',
            model: 'deepseek-v4-flash',
            into: 'items',
            instruction: '逐条判四态',
            input: {
              from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
              fields: { param_name: { col: 'param_name' }, constraint_type: { col: 'constraint_type' } },
            },
            output: { type: 'object' },
          },
          {
            kind: 'project',
            into: 'parts.parameter',
            from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
            fields: {
              internal_pn: 'XL2596S-5.0',
              param_name: { col: 'param_name' },
              /* **行由字典决定,AI 出参只是查表** —— 它漏一条,那一格就是空 */
              param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' },
            },
          },
        ],
      },
      ctx,
    );
    expect(seen).toEqual([
      { param_name: '输入电压范围', constraint_type: 'hard' },
      { param_name: '输出电流', constraint_type: 'soft' },
    ]);
    const values = [...drafts.values()].filter((d) => d.object === 'parameter').map((d) => d.patch['param_value']);
    expect(values.sort()).toEqual(['match', 'unknown']);
  });

  it('AI 漏了一条:那一行的格子是空的 —— 行数不由它决定', async () => {
    const { runner, drafts, ctx } = harness(
      { 'parts.type_param': [{ type_id: 1, param_name: 'A' }, { type_id: 1, param_name: 'B' }], typeId: 1 },
      { analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } },
    );
    await runner.run(
      {
        do: [
          { kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, output: { type: 'object' } },
          { kind: 'project', into: 'parts.parameter', from: { each: 'parts.type_param' }, fields: { internal_pn: 'P', param_name: { col: 'param_name' }, param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' } } },
        ],
      },
      ctx,
    );
    const rows = [...drafts.values()].filter((d) => d.object === 'parameter');
    expect(rows).toHaveLength(2);
    expect(rows.find((r) => r.key['param_name'] === 'B')?.patch['param_value']).toBe('');
  });

  it('有卡在等人交回时不调 AI —— 只算、只写', async () => {
    let called = 0;
    const { runner, ctx } = harness(
      { 'parts.type_param': [{ type_id: 1, param_name: 'A' }] },
      { analyze: { run: () => { called += 1; return Promise.resolve({ object: { items: [] } }); } } },
    );
    await runner.run(
      { do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { p: { col: 'param_name' } } }, output: { type: 'object' } }] },
      { ...ctx, quiet: true },
    );
    expect(called).toBe(0);
  });
});
  • Step 3: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts -t analyze Expected: FAIL —— 动词尚未实现:analyze

  • Step 4: 在执行器里实现 analyze 分支

在 auto-runner.ts 的 runAction 里,if (action.kind === 'project') { … } 之后、最后那句 throw 之前插入:

        if (action.kind === 'analyze') {
          const payload = expandSource(action.input.from, base).map(({ row, outer }) => {
            const item: Record<string, unknown> = {};
            for (const [name, expr] of Object.entries(action.input.fields)) {
              item[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed: item });
            }
            return item;
          });
          const { object } = await deps.analyze.run({
            taskId: context.taskId,
            model: action.model,
            instruction: action.instruction,
            payload,
            outputSchema: action.output,
            label: `${context.stepId}#${index + 1}`,
          });
          /* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */
          const rows = object[action.into];
          results[`result:${action.into}`] = Array.isArray(rows) ? rows : [];
          await deps.memory.append({
            taskId: context.taskId,
            kind: 'result',
            title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`,
            detail: { auto: `${context.stepId}#${index + 1}`, internal: true, model: action.model },
          });
          /* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */
          scope = { ...scope, ...results };
          return { wrote: 0, dropped: 0 };
        }

注意:results 与 scope 是闭包里的变量;analyze 之后不要把 dirty 置真 (它没写草稿层,刷新反而会把 result:* 冲掉;上面那句 scope = { ...scope, ...results } 已经够了)。

  • Step 5: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts Expected: PASS(11 个用例)

  • Step 6: 写网关的失败测试
// apps/api/src/app/__tests__/analyze-gateway.test.ts
import { describe, expect, it } from 'vitest';
import { taskIdSchema } from '@contracts';
import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts';

const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } };
const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' };

describe('analyze 网关', () => {
  it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => {
    const seen: Array<Record<string, unknown>> = [];
    const gateway = createAnalyzeGateway({
      generate: (c: Record<string, unknown>) => {
        seen.push(c);
        return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
      },
    } as never);
    const out = await gateway.run(call);
    expect(out.object).toEqual({ items: [{ p: 'A' }] });
    expect(seen[0]?.['system']).toBe('逐条判四态');
    expect(String(seen[0]?.['prompt'])).toContain('"p": "A"');
    expect(seen[0]?.['reasoning']).toBe('off');
  });

  it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => {
    let n = 0;
    const gateway = createAnalyzeGateway({
      generate: () => {
        n += 1;
        return Promise.resolve(
          n === 1
            ? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
            : { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
        );
      },
    } as never);
    const out = await gateway.run(call);
    expect(n).toBe(2);
    expect(out.object).toEqual({ items: [] });
  });

  it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => {
    let n = 0;
    const gateway = createAnalyzeGateway({
      generate: () => {
        n += 1;
        return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
      },
    } as never);
    await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
    expect(n).toBe(2);
  });
});
  • Step 7: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/app/__tests__/analyze-gateway.test.ts Expected: FAIL —— Failed to resolve import "../wiring/analyze-gateway.ts"

  • Step 8: 写网关
// apps/api/src/app/wiring/analyze-gateway.ts
/**
 * `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。
 *
 * 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。
 * 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 ——
 * 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。
 */
import type { ModelGateway } from '@modules/llm';
import type { AnalyzeGateway } from '@modules/loop-engine';

const RETRY_HINT =
  '上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。';

export function createAnalyzeGateway(llm: Pick<ModelGateway, 'generate'>): AnalyzeGateway {
  return {
    async run(input) {
      const prompt = JSON.stringify(input.payload, null, 2);
      const call = {
        model: input.model,
        system: input.instruction,
        prompt,
        /* 判断归判定表与规则,模型只做归类 —— 隐藏推理按秒计,这一档一律关 */
        reasoning: 'off' as const,
        temperature: 0.2,
        outputSchema: input.outputSchema as Record<string, unknown>,
        taskId: input.taskId,
        label: input.label,
      };
      const first = await llm.generate(call);
      if (typeof first.object === 'object' && first.object !== null) {
        return { object: first.object as Record<string, unknown> };
      }
      const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` });
      if (typeof second.object === 'object' && second.object !== null) {
        return { object: second.object as Record<string, unknown> };
      }
      throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
    },
  };
}
  • Step 9: 换掉 engines.ts 里的占位

在 apps/api/src/app/wiring/engines.ts 里把 Task 4 加的那行占位

analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) },

换成:

analyze: createAnalyzeGateway(deps.llm),

并在该文件顶部 import 段加:

import { createAnalyzeGateway } from './analyze-gateway.ts';

deps.llm 就是装配层已有的 ModelGateway。若该变量在你手上叫别的名字, 用 grep -n "ModelGateway" apps/api/src/app/wiring/engines.ts 找它。

  • Step 10: 跑测试与全量门禁

Run:

pnpm exec vitest run apps/api/src/app/__tests__/analyze-gateway.test.ts
pnpm gates

Expected: 前者 3 个用例 PASS;pnpm gates 全绿。

  • Step 11: 提交
git add apps/api/src/modules/loop-engine apps/api/src/app/wiring/analyze-gateway.ts apps/api/src/app/wiring/engines.ts apps/api/src/app/__tests__/analyze-gateway.test.ts
git commit -m "$(cat <<'EOF'
feat(loop-engine): analyze 动词 —— 系统组装入参、声明出参 schema、系统把结果落表

AI 在这里只是一个纯函数:不给工具、不给记忆、不多轮。
行数不由它决定 —— 出参是拿主键去 lookup 的一张查表,它漏一条那一格就是空,
required 立刻把它变成 _missing > 0,提交闸与放行门当场拦住。
出参不成形就地重问一次,再不成抛出去交人。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 6: card 与 submit 两个动词

Files:

  • Modify: apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts(补两个分支)
  • Modify: apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts(补 5 个用例)
  • Modify: apps/api/src/modules/human-tool/internal/domain/card-rules.ts:92(审核卡放宽到单选项)
  • Modify: apps/api/src/modules/human-tool/__tests__/(该目录下测 card-rules 的那个文件,补 2 个用例)

Interfaces:

  • Consumes: Task 4 的 runAction / AutoOutcome;现有 CardGateway.issue / CardGateway.decide

  • Produces: AutoOutcome.issuedCardId 与 AutoOutcome.submit 两个字段真的会被填上(Task 7 的 turn-runner 读它们)

  • Step 1: 写失败的测试

在 auto-runner.test.ts 末尾追加:

describe('card 与 submit', () => {
  it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => {
    const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
    const out = await runner.run(
      {
        do: [
          {
            kind: 'card',
            as: 'execution',
            variant: 'data',
            title: { cat: ['数据工单 · 补齐 ', { in: 'pn' }] },
            guide: ['选一个芯片类型', '把参数值填齐'],
            standard: '必填格全部填上,红带消失',
            basis: [{ k: '候选料号', v: { in: 'pn' } }],
            gateLedgers: ['parts.profile', 'parts.parameter'],
          },
        ],
      },
      ctx,
    );
    expect(out.issuedCardId).toBe('card-1');
    expect(cards.issued[0]).toMatchObject({
      workOrder: 'execution',
      itemId: 'S1',
      card: {
        mode: 'execution',
        title: '数据工单 · 补齐 XL2596S-5.0',
        variant: 'data',
        steps: ['选一个芯片类型', '把参数值填齐'],
        standard: '必填格全部填上,红带消失',
        basis: [{ k: '候选料号', v: 'XL2596S-5.0' }],
        gateLedgers: ['parts.profile', 'parts.parameter'],
        fields: [],
      },
    });
  });

  it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => {
    const { runner, notes, ctx } = harness({});
    const out = await runner.run(
      { do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] },
      { ...ctx, carded: true },
    );
    expect(out.issuedCardId).toBeUndefined();
    expect(notes.some((n) => n.includes('已经发过'))).toBe(true);
  });

  it('decide + review:摆成验收工单,单一选项也发得出去', async () => {
    const { runner, ctx } = harness({});
    const out = await runner.run(
      {
        do: [
          {
            kind: 'card',
            as: 'decide',
            variant: 'review',
            title: '差异评估结论审核',
            ask: '逐条核对过了吗?',
            options: [{ value: 'approve', label: '确认通过' }],
            basis: [{ k: '候选', v: 'XL2596S-5.0' }],
          },
        ],
      },
      ctx,
    );
    expect(out.issuedCardId).toBe('card-2');
  });

  it('发了卡就**收拍**:排在后面的笔一律不跑', async () => {
    const { runner, drafts, ctx } = harness({});
    await runner.run(
      {
        do: [
          { kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] },
          { kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } },
        ],
      },
      ctx,
    );
    expect(drafts.size).toBe(0);
  });

  it('submit:回给 turn-runner,理由现算;有卡在等人时不 submit', async () => {
    const { runner, ctx } = harness({ n: 3 });
    const out = await runner.run({ do: [{ kind: 'submit', rationale: { cat: ['已判定 ', { in: 'n' }, ' 条参数'] } }] }, ctx);
    expect(out.submit).toEqual({ rationale: '已判定 3 条参数' });

    const quiet = await runner.run({ do: [{ kind: 'submit', rationale: 'x' }] }, { ...ctx, quiet: true });
    expect(quiet.submit).toBeUndefined();
  });
});

上面第一个用例要断言卡骨架,所以先把 Task 4 写的 harness() 里那两个发卡端口改成记录入参:

  const cards = { issued: [] as Array<Record<string, unknown>> };

(放在 const notes: string[] = []; 旁边),并把 deps.cards 里这两行:

      issue: () => Promise.resolve({ id: 'card-1' }),
      decide: () => Promise.resolve({ id: 'card-2' }),

改成:

      issue: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); },
      decide: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); },

最后把 harness 的返回值加上 cards:

  return { runner: createAutoRunner(deps as never), drafts, notes, cards, ctx };
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts -t "card 与 submit" Expected: FAIL —— 动词尚未实现:card

  • Step 3: 实现两个分支

在 auto-runner.ts 的 runAction 里,analyze 分支之后、最后那句 throw 之前插入:

        if (action.kind === 'card') {
          /*
           * **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** ——
           * 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。
           */
          if (context.carded) {
            await deps.memory.append({
              taskId: context.taskId,
              kind: 'observation',
              title: `这一步已经发过卡,不再发第二张`,
              detail: { auto: `${context.stepId}#${index + 1}`, internal: true },
            });
            return { wrote: 0, dropped: 0, stop: true };
          }
          const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) }));
          if (action.as === 'decide') {
            const options = action.options ?? [];
            if (options.length === 0) throw new AutoRecipeError(index, '决策卡一个选项都没有');
            const { id } = await deps.cards.decide({
              taskId: context.taskId,
              ownerId: context.ownerId,
              humanCallCap: context.humanCallCap,
              ask: evalText(action.ask ?? action.title, base),
              options: options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }) })),
              basis,
              itemId: context.stepId,
              ...(action.variant === undefined ? {} : { variant: action.variant }),
            });
            return { wrote: 0, dropped: 0, cardId: id, stop: true };
          }
          const { id } = await deps.cards.issue({
            taskId: context.taskId,
            ownerId: context.ownerId,
            humanCallCap: context.humanCallCap,
            workOrder: 'execution',
            itemId: context.stepId,
            card: {
              mode: 'execution',
              title: evalText(action.title, base),
              ask: evalText(action.title, base),
              basis,
              /* 零输入:填报全在工作区的件上(2026-08-02 用户定) */
              fields: [],
              ...(action.variant === 'data' ? { variant: 'data' } : {}),
              ...(action.guide === undefined ? {} : { steps: [...action.guide] }),
              ...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
              ...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }),
            },
          });
          return { wrote: 0, dropped: 0, cardId: id, stop: true };
        }

        if (action.kind === 'submit') {
          return { wrote: 0, dropped: 0, submit: { rationale: evalText(action.rationale, base) }, stop: true };
        }

variant 只在 CardGateway.decide 上是显式参数;执行工单的 variant:'data' 是卡骨架里的一个键 (见 executionWorkOrderSchema.variant),所以走 card 那个对象。这两处不对称是既有契约的样子,别改它。

  • Step 4: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts Expected: PASS(16 个用例)

  • Step 5: 给审核卡放宽单选项写失败的测试

先找到测 card-rules 的那个文件:

Run: grep -rln "options-missing" apps/api/src/modules/human-tool/__tests__/

在它末尾追加(describe 的名字照该文件既有的风格):

describe('审核卡的选项数', () => {
  it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => {
    const findings = validateCard({ k: 'decision', ask: '往哪走', options: [{ value: 'a', label: 'A' }] } as never);
    expect(findings.some((f) => f.code === 'options-missing')).toBe(true);
  });

  it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => {
    const findings = validateCard({ k: 'decision', variant: 'review', ask: '核对过了吗', options: [{ value: 'approve', label: '确认通过' }] } as never);
    expect(findings.some((f) => f.code === 'options-missing')).toBe(false);
  });
});

validateCard 是那个文件里已经在用的入口函数名;若它叫别的名字, 用 grep -n "options-missing" apps/api/src/modules/human-tool/internal/domain/card-rules.ts 往上找到它所在的导出函数。

  • Step 6: 跑一遍,确认第二条失败

Run: pnpm exec vitest run apps/api/src/modules/human-tool/__tests__/ -t "审核卡的选项数" Expected: 第一条 PASS,第二条 FAIL(options-missing 仍被报出来)

  • Step 7: 放宽

把 apps/api/src/modules/human-tool/internal/domain/card-rules.ts:92 那一行:

      if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });

改成:

      /*
       * **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 ——
       * 改全在件上就地做,卡上只有「确认通过」一个动作。
       * 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。
       */
      const floor = card.variant === 'review' ? 1 : 2;
      if (card.options.length < floor) {
        out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' });
      }
  • Step 8: 跑测试与全量门禁

Run:

pnpm exec vitest run apps/api/src/modules/human-tool/__tests__/
pnpm gates

Expected: 全绿。

  • Step 9: 提交
git add apps/api/src/modules/loop-engine apps/api/src/modules/human-tool
git commit -m "$(cat <<'EOF'
feat(loop-engine): card 与 submit 两个动词;审核卡放宽到单选项

ADR-014 §8 原写「不发决策卡、不 submit」—— 那是假设还有 agent 在场时的保守话。
全程无 agent 的活动没有别人来干这两件事,不开口最后两步就没人做。
护栏没松:submit 仍要过提交门(判据全绿),系统提交不比模型提交宽松。
发过的卡不再发第二张;发了卡就收拍,排在后面的笔一律不跑。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 7: turn-runner 接线 —— 拍首跑配方

Files:

  • Create: apps/api/src/modules/loop-engine/internal/domain/live-step.ts
  • Create: apps/api/src/modules/loop-engine/__tests__/live-step.test.ts
  • Modify: apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts(三处)
  • Modify: apps/api/src/modules/loop-engine/CONTRACT.md

Interfaces:

  • Consumes: Task 4-6 的 createAutoRunner / AutoOutcome

  • Produces: function liveStepOf<S extends { item: string }>(steps: readonly S[], settledIds: ReadonlySet<string>, onStage: readonly { id: string }[]): S | undefined

  • Step 1: 写 liveStepOf 的失败测试

// apps/api/src/modules/loop-engine/__tests__/live-step.test.ts
import { describe, expect, it } from 'vitest';
import { liveStepOf } from '../internal/domain/live-step.ts';

const steps = [
  { item: 'S1', guide: '' },
  { item: 'S2', guide: '' },
  { item: 'T_release', guide: '' },
];

describe('liveStepOf:这一单此刻在办哪一步', () => {
  it('在场的第一个还没收掉的项', () => {
    expect(liveStepOf(steps, new Set(), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S1');
  });

  it('**收掉的项要跳过** —— 带 repetitionRule 的项收了之后仍是 Enabled,不跳就整单钉在第一步', () => {
    expect(liveStepOf(steps, new Set(['S1']), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S2');
  });

  it('在场的都收完了 → undefined', () => {
    expect(liveStepOf(steps, new Set(['S1', 'S2']), [{ id: 'S1' }, { id: 'S2' }])).toBeUndefined();
  });

  it('在场的项没有剧本 → undefined(没写剧本的活动照旧跑)', () => {
    expect(liveStepOf(steps, new Set(), [{ id: 'X' }])).toBeUndefined();
  });

  it('次序按 onStage 给的次序,不按 steps 的次序', () => {
    expect(liveStepOf(steps, new Set(), [{ id: 'S2' }, { id: 'S1' }])?.item).toBe('S2');
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/live-step.test.ts Expected: FAIL —— Failed to resolve import "../internal/domain/live-step.ts"

  • Step 3: 提纯出 liveStepOf
// apps/api/src/modules/loop-engine/internal/domain/live-step.ts
/**
 * **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。
 *
 * 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**,
 * 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了,
 * 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。
 */
export function liveStepOf<S extends { readonly item: string }>(
  steps: readonly S[],
  settledIds: ReadonlySet<string>,
  onStage: readonly { readonly id: string }[],
): S | undefined {
  for (const entry of onStage) {
    if (settledIds.has(entry.id)) continue;
    const step = steps.find((s) => s.item === entry.id);
    if (step !== undefined) return step;
  }
  return undefined;
}
  • Step 4: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/live-step.test.ts Expected: PASS(5 个用例)

  • Step 5: 让 turn-runner 用它(等价替换,行为不变)

在 turn-runner.ts 里找到这一段(约 1927-1935 行):

      const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries]
        .filter((e) => !settledIds.has(e.id))
        .map((e) => stepOf(e.id))
        .find((x) => x !== undefined);

替换为:

      const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);

并在文件顶部 import 段加:

import { liveStepOf } from '../domain/live-step.ts';
  • Step 6: 跑全量确认等价

Run: pnpm exec vitest run apps/api/src/modules/loop-engine Expected: 全绿(这一步只是提纯,行为一个字不变)

  • Step 7: 在「有卡在等人」那条路上插入静默配方段

在 turn-runner.ts 里找到这一段(约 1682 行):

      const waiting = await deps.cards.unanswered(taskId);
      if (waiting.length > 0) {
        await deps.memory.append({

在 if (waiting.length > 0) { 之后、await deps.memory.append({ 之前插入:

        /*
         * **有卡在等人,配方照样跑 —— 只算、只写**(spec §5.4)。
         * 人在件上按了保存,系统就该按最新的草稿把该铺的行铺出来(「保存即铺格」);
         * 但不发新卡、不 submit、不调 AI —— 叫了人就等人。
         */
        /* `steps` 要到第 1842 行才 const 出来,这儿够不着 —— 从 model 上现取 */
        const quietStep = liveStepOf(
          model.steps ?? [],
          new Set(runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)),
          [...enabled.inProgress, ...enabled.entries],
        );
        if (quietStep?.auto !== undefined) {
          await autoRunner.run(quietStep.auto, {
            taskId,
            stepId: quietStep.item,
            ownerId: (await deps.tasks.of(taskId)).ownerId,
            humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
            quiet: true,
            carded: true,
          });
        }

deps.tasks.of(taskId) 回的是 { taskId, activityId, ownerId, humanCallCap } —— 该文件第 675 / 1535 行 已经这么取过两次(const context = await deps.tasks.of(taskId);)。若这一段所在的作用域里已经有 context 这个变量,直接用 context.ownerId / context.humanCallCap,别再取一遍。

  • Step 8: 在正常那条路上插入完整配方段

在 turn-runner.ts 里找到 const owesCard = 那一段(约 1948 行)之前���入:

      /*
       * **配方段**(spec §5.8 ③④⑤):当前这一步声明了 `auto` 就先把确定的事做完。
       *   · 发了卡 / submit → 这一拍到此为止(零 token,不问模型);
       *   · 没发卡且这一步没有 `guide` → 重求 doneWhen 收步(下面的 sweepRipe 会做);
       *   · 有 `guide` → 照常往下问模型(半 auto)。
       * 出错 → 留痕交人,不重试、不退回模型。
       */
      if (current?.auto !== undefined) {
        const auto = await autoRunner.run(current.auto, {
          taskId,
          stepId: current.item,
          ownerId: (await deps.tasks.of(taskId)).ownerId,
          humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
          quiet: false,
          carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true,
        });
        if (auto.failedAt !== undefined) {
          return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
        }
        if (auto.submit !== undefined) {
          const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale);
          return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
        }
        if (auto.issuedCardId !== undefined) {
          /* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */
          const touched = markWorldTouched(stateRuntime, current.item);
          await deps.state.save(taskId, model, parsed, touched);
          /* 这一步声明了画面才推 —— `showView` 的第二参不可选,没声明就沿用上一帧(与既有行为一致) */
          if (current.view !== undefined && current.view !== '') {
            await showView(taskId, current.view, touched, current.view);
          }
          return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
        }
        if (auto.wrote > 0 || auto.dropped > 0) {
          /* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */
          const freshScope = await deps.data.view(taskId);
          const reswept = await sweepRipe(stateRuntime, stateEnabled, freshScope);
          if (reswept.runtime !== stateRuntime) {
            await deps.state.save(taskId, model, parsed, reswept.runtime);
            return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'progressed' }, status: 'running' };
          }
        }
        if (current.guide.trim() === '') {
          /* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人 */
          await deps.memory.append({
            taskId,
            kind: 'result',
            title: `「${current.item}」的配方跑完了,可这一步还没算办完 —— 交给你看一眼`,
            detail: { auto: current.item },
          });
          return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
        }
      }

三个你要照该文件既有写法对齐的名字:

  • markWorldTouched —— 用 grep -n "worldTouched" apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts 找那个把项标成「动过世界」的函数;它已存在(human/decide/entry 三条路都在用)。
  • showView —— 第 559 行定义,签名 (taskId, template: string, runtime, preferred?);第二参不可选。
  • raiseAcceptance —— 第 1500 行定义,签名 (taskId, runtime, rationale, fields?)。
  • Step 9: 在 createTurnRunner 里造出 autoRunner

在 createTurnRunner(deps) 函数体开头(return { ... } 之前)加:

  const autoRunner = createAutoRunner({
    data: deps.data,
    cards: deps.cards,
    memory: deps.memory,
    analyze: deps.analyze,
    sfeel: deps.sfeel,
  });

并在文件顶部 import 段加:

import { createAutoRunner } from './auto-runner.ts';
  • Step 10: 确认测试脚手架的 analyze 桩已经在

Task 4 已经在 turn-runner-acceptance.test.ts 的 const deps: TurnRunnerDeps = { 里加过这一行:

    analyze: { run: async () => ({ object: {} }) } as never,

Run: grep -n "analyze:" apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts Expected: 有这一行。没有就现在补上 —— 少了它下面的用例连编译都过不去。

  • Step 11: 写接线的五个失败用例

追加到 turn-runner-acceptance.test.ts 末尾:

describe('auto 步:配方在拍首跑', () => {
  /** 一步的活动:那一步声明 auto */
  const AUTO_MODEL: CaseModel = {
    name: '一步的活动',
    autoComplete: false,
    items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
    sentries: [],
  } as never;
  const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never;

  it('配方发了卡 → 这一拍一次都不问模型,状态 awaiting', async () => {
    const h = harness({
      parsed: AUTO_MODEL,
      runtime: AUTO_RUNTIME,
      sfeelCondition: () => false,
      steps: [
        {
          item: 'S1',
          guide: '',
          writes: [],
          reads: [],
          tools: [],
          auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
        },
      ],
    });
    const out = await h.runner.runTurn(TASK);
    expect(h.decided).toHaveLength(0);
    expect(out.status).toBe('awaiting');
    expect(h.issued.map((i) => i.workOrder)).toEqual(['execution']);
  });

  it('配方只写不发卡、这一步因此办完 → 收步,还是不问模型', async () => {
    const h = harness({
      parsed: AUTO_MODEL,
      runtime: AUTO_RUNTIME,
      sfeelCondition: (_expr, scope) => Number(scope['quote_intake_record_rows'] ?? 0) > 0,
      viewFn: (written) => ({ quote_intake_record_rows: written.length }),
      steps: [
        {
          item: 'S1',
          guide: '',
          writes: [],
          reads: [],
          tools: [],
          doneWhen: 'quote_intake_record_rows > 0',
          auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'LM2596S-5.0', eau: 1000 } }] },
        },
      ],
    });
    const out = await h.runner.runTurn(TASK);
    expect(h.decided).toHaveLength(0);
    expect(h.written.map((w) => w.key)).toEqual([{ pn: 'LM2596S-5.0' }]);
    expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
  });

  it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
    const h = harness({
      parsed: AUTO_MODEL,
      runtime: AUTO_RUNTIME,
      unanswered: [{ id: 'card-9', title: '补数据' }],
      sfeelCondition: () => false,
      viewFn: (written) => ({ n: written.length }),
      steps: [
        {
          item: 'S1',
          guide: '',
          writes: [],
          reads: [],
          tools: [],
          auto: {
            do: [
              { kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
              { kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] },
            ],
          },
        },
      ],
    });
    await h.runner.runTurn(TASK);
    expect(h.written).toHaveLength(1);
    expect(h.issued).toHaveLength(0);
  });

  it('配方抛错(地址不在台账声明里)→ 交人,不问模型、不重试', async () => {
    const h = harness({
      parsed: AUTO_MODEL,
      runtime: AUTO_RUNTIME,
      sfeelCondition: () => false,
      steps: [
        {
          item: 'S1',
          guide: '',
          writes: [],
          reads: [],
          tools: [],
          auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
        },
      ],
    });
    const out = await h.runner.runTurn(TASK);
    expect(h.decided).toHaveLength(0);
    expect(out.status).toBe('awaiting');
    expect(h.appended.some((a) => a.title.includes('nope.nope'))).toBe(true);
  });

  it('这一步有 guide:配方跑完照常问模型(半 auto)', async () => {
    const h = harness({
      parsed: OPEN_MODEL,
      runtime: OPEN_RUNTIME,
      sfeelCondition: () => false,
      viewFn: (written) => ({ n: written.length }),
      steps: [
        {
          item: 'i1',
          guide: '把结论写实',
          writes: [],
          reads: [],
          tools: [],
          auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }] },
        },
      ],
      action: { kind: 'idle', rationale: '看过了' },
    });
    await h.runner.runTurn(TASK);
    expect(h.written).toHaveLength(1);
    expect(h.decided).toHaveLength(1);
  });
});
  • Step 12: 跑这五条,确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts -t "auto 步" Expected: 5 个用例全 PASS。若第二条报「S1 还是 Available」,多半是配方段插在了 sweepRipe 之前 —— 它必须在 const swept = await sweepRipe(runtime, enabled, scope); 之后(那时才有 stateRuntime / current)。

  • Step 13: 跑全量门禁

Run: pnpm gates Expected: 全绿。E04 / I01 / W05 的既有用例必须一条不改就过 —— 它们的剧本里没有 auto,这一段整个不进。

  • Step 14: 补 CONTRACT.md

在 apps/api/src/modules/loop-engine/CONTRACT.md 的不变量清单里加三条:

- **auto 步不问模型**:`PlanStep.auto` 存在时,拍首先跑配方;发了卡 / submit 就收拍(零 token)。
  有 `guide` 的照常问模型(半 auto);两者都没有且 `doneWhen` 不成立 → 交人(配方没做到位,是包的 bug)。
- **有未答的卡在场时,配方只跑 `project` / `say`** —— 只算、只写,不发新卡、不 submit、不调 AI。
- **配方每一笔之前刷新任务视图**:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。
  • Step 15: 提交
git add apps/api/src/modules/loop-engine
git commit -m "$(cat <<'EOF'
feat(loop-engine): 拍首跑配方 —— 确定的活由引擎做完,剩下的才问模型

liveStepOf 从 turn-runner 提纯成纯函数(带 repetitionRule 的项收了仍 Enabled,
不跳过已收掉的就整单钉在第一步 —— 这条实测栽过)。
有卡在等人时配方照样跑但只算只写:人在件上按了保存,该铺的行就铺出来。
老活动一个字不用改:剧本里没有 auto,这一段整个不进。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 8: 卡答复即收步

Files:

  • Modify: apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts(导出一个纯函数)
  • Modify: apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts(配方段末尾加一段)
  • Modify: apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts(补 2 个用例)

Interfaces:

  • Consumes: Task 7 的配方段

  • Produces: function recipeIssuesCard(recipe: AutoRecipe): boolean

  • Step 1: 写失败的测试

追加到 turn-runner-acceptance.test.ts 的 describe('auto 步:配方在拍首跑', …) 里:

  it('卡答过了、这一步又没有 doneWhen → 收步(人已经签了,收步是照实记录)', async () => {
    const h = harness({
      parsed: AUTO_MODEL,
      runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
      unanswered: [],
      sfeelCondition: () => false,
      steps: [
        {
          item: 'S1',
          guide: '',
          writes: [],
          reads: [],
          tools: [],
          auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
        },
      ],
    });
    const out = await h.runner.runTurn(TASK);
    expect(h.issued).toHaveLength(0);
    expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
  });

  it('卡还没发过就不收 —— 那才是替人签字', async () => {
    const h = harness({
      parsed: AUTO_MODEL,
      runtime: AUTO_RUNTIME,
      sfeelCondition: () => false,
      steps: [
        {
          item: 'S1',
          guide: '',
          writes: [],
          reads: [],
          tools: [],
          auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
        },
      ],
    });
    const out = await h.runner.runTurn(TASK);
    expect(h.issued).toHaveLength(1);
    expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
  });
  • Step 2: 跑一遍,确认第一条失败

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts -t "卡答过了" Expected: FAIL —— S1 仍是 Active

  • Step 3: 导出纯函数

在 auto-runner.ts 的 createAutoRunner 之前加:

/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */
export function recipeIssuesCard(recipe: AutoRecipe): boolean {
  return recipe.do.some((action) => action.kind === 'card');
}
  • Step 4: 在 turn-runner 的配方段末尾加收步

在 Task 7 Step 8 插入的那一段里,if (current.guide.trim() === '') { … } 之前插入:

        /*
         * **卡答过了就收步**(spec §5.5)。走到这儿的四个前提都成立:
         * 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、
         * 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** ——
         * 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。
         */
        const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
        const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
        if (noDoneWhen && carded && recipeIssuesCard(current.auto)) {
          const active = readyToWork(parsed, stateRuntime, current.item);
          const settled = transitionItem(active, current.item, 'Completed');
          await deps.memory.append({
            taskId,
            kind: 'engine-transition',
            /* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */
            title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`,
            detail: { internal: true, item: current.item, auto: current.item },
          });
          await deps.state.save(taskId, model, parsed, settled);
          return { runtime: settled, enabled: stateEnabled, navigation: { kind: 'progressed' }, status: 'running' };
        }

并在 auto-runner.ts 的 import 那一行加上 recipeIssuesCard:

import { createAutoRunner, recipeIssuesCard } from './auto-runner.ts';

readyToWork 与 transitionItem 是 sweepRipe 里已经在用的两个函数(同文件已 import)。

  • Step 5: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts Expected: 全绿(含新加的 7 条 auto 用例)

  • Step 6: 提交
git add apps/api/src/modules/loop-engine
git commit -m "$(cat <<'EOF'
feat(loop-engine): auto 步的卡答过了就收步

四个前提都成立才收:声明了 auto、卡发过、这一拍没有未答的卡、配方也没再发新卡。
卡还没发过就不收 —— 那才是替人签字。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 9: 静态审 —— 配方写错在装包时就拒

Files:

  • Modify: apps/api/src/modules/activity-registry/internal/domain/static-audit.ts
  • Modify: 该模块 __tests__/ 下测 staticAudit 的那个文件(用 grep -rln "staticAudit" apps/api/src/modules/activity-registry/__tests__/ 找它)

Interfaces:

  • Consumes: Task 1 的 AutoRecipe

  • Produces: function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[],并由 staticAudit 汇总

  • Step 1: 写失败的测试

追加到 static-audit 的测试文件末尾(manifestFixture() 用该文件里既有的那个造清单的 helper; 若它叫别的名字,grep -n "ActivityManifest = " 找它):

describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () => {
  const withRecipe = (auto: unknown) => {
    const base = manifestFixture();
    return {
      ...base,
      assets: {
        ...base.assets,
        businessModel: {
          ...base.assets.businessModel,
          steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
        },
      },
    } as never;
  };

  it('台账地址不在 declarations.ledger 里 → error', () => {
    const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }));
    expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
  });

  it('列名不在该对象的声明里 → error(列名写错 = 那一格永远是空的)', () => {
    const findings = autoRecipeFindings(
      withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }),
    );
    expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true);
  });

  it('each / lookup 的地址同样要在声明里', () => {
    const findings = autoRecipeFindings(
      withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { each: 'mine:nope.nope' }, fields: {} }] }),
    );
    expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true);
  });

  it('in: 与 result: 不是台账地址,不该被当成台账来审', () => {
    const findings = autoRecipeFindings(
      withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { each: 'in:pns' }, fields: {} }] }),
    );
    expect(findings).toEqual([]);
  });

  it('analyze 的出参没人消费 → warning(白烧一次模型调用)', () => {
    const findings = autoRecipeFindings(
      withRecipe({ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { once: true }, fields: {} }, output: { type: 'object' } }] }),
    );
    expect(findings.some((f) => f.severity === 'warning' && f.message.includes('items'))).toBe(true);
  });

  it('submit 那一笔所在的步没有人工验收清单 → error(系统提交不比模型提交宽松)', () => {
    const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] }));
    expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true);
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/activity-registry/__tests__/ -t "配方静态审" Expected: FAIL —— autoRecipeFindings is not defined

  • Step 3: 写审计

在 static-audit.ts 的 loopFindings 之后插入:

/**
 * **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。
 *
 * 代码错是必然的错,模型错是概率的错。必然的错好查 —— 前提是它在装包那一刻就被拦下,
 * 而不是等到第一单跑起来才现形。
 */
export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] {
  const out: StaticAuditFinding[] = [];
  const ledgers = new Map(
    manifest.declarations.ledger.map((l) => [`${l.domain}.${l.object}`, new Set(l.fields.map((f) => f.name))]),
  );
  const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name));

  /** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */
  const checkAddress = (raw: string, at: string): void => {
    if (raw.startsWith('in:') || raw.startsWith('result:')) return;
    const address = raw.startsWith('mine:') ? raw.slice('mine:'.length) : raw;
    if (!ledgers.has(address)) {
      out.push({
        code: 'schema-invalid',
        severity: 'error',
        message: `配方引的台账地址不在声明里:${address}(写的授权面是台账声明)`,
        at,
      });
    }
  };

  /** 表达式里也可能藏着 lookup 地址,递归找一遍 */
  const walkExpr = (expr: unknown, at: string): void => {
    if (typeof expr !== 'object' || expr === null) return;
    const node = expr as Record<string, unknown>;
    if (typeof node['lookup'] === 'string') {
      checkAddress(node['lookup'], at);
      for (const value of Object.values(node['by'] ?? {})) walkExpr(value, at);
      if (node['else'] !== undefined) walkExpr(node['else'], at);
    }
    if (Array.isArray(node['cat'])) for (const part of node['cat']) walkExpr(part, at);
    if (node['fmt'] !== undefined) walkExpr(node['fmt'], at);
    if (node['then'] !== undefined) walkExpr(node['then'], at);
    if (typeof node['when'] === 'string' && node['else'] !== undefined) walkExpr(node['else'], at);
  };

  const walkSource = (source: unknown, at: string): void => {
    if (typeof source !== 'object' || source === null) return;
    const node = source as Record<string, unknown>;
    if (typeof node['each'] === 'string') checkAddress(node['each'], at);
    for (const value of Object.values(node['where'] ?? {})) walkExpr(value, at);
    if (typeof node['then'] === 'object' && node['then'] !== null) walkSource(node['then'], at);
    if (Array.isArray(node['rows'])) {
      for (const row of node['rows']) for (const value of Object.values(row as Record<string, unknown>)) walkExpr(value, at);
    }
  };

  for (const step of manifest.assets.businessModel.steps ?? []) {
    const recipe = step.auto;
    if (recipe === undefined) continue;
    const at = `assets.businessModel.steps[${step.item}].auto`;
    const consumed = new Set<string>();

    for (const [index, action] of recipe.do.entries()) {
      const where = `${at}.do[${index}]`;
      if (action.kind === 'project') {
        checkAddress(action.into, where);
        walkSource(action.from, where);
        const columns = ledgers.get(action.into);
        for (const [name, expr] of Object.entries(action.fields)) {
          walkExpr(expr, where);
          if (columns !== undefined && !columns.has(name)) {
            out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where });
          }
        }
      }
      if (action.kind === 'analyze') {
        walkSource(action.input.from, where);
        for (const expr of Object.values(action.input.fields)) walkExpr(expr, where);
        if (!manifest.declarations.models.includes(action.model)) {
          out.push({ code: 'schema-invalid', severity: 'error', message: `配方用的模型没在 declarations.models 里声明:${action.model}`, at: where });
        }
      }
      if (action.kind === 'card') {
        for (const address of action.gateLedgers ?? []) checkAddress(address, where);
        if (action.as === 'decide' && (action.options ?? []).length === 0) {
          out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where });
        }
      }
      if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) {
        out.push({
          code: 'schema-invalid',
          severity: 'error',
          message: 'submit 那一步必须声明人工验收清单(acceptanceChecklist)—— 系统提交不比模型提交宽松',
          at: where,
        });
      }
      /* 找出 `result:xxx` 被谁消费了 —— analyze 的出参没人用,就是白烧一次模型调用 */
      const text = JSON.stringify(action);
      for (const hit of text.matchAll(/"result:([A-Za-z0-9_]+)"/g)) consumed.add(hit[1] ?? '');
    }

    for (const action of recipe.do) {
      if (action.kind !== 'analyze') continue;
      if (!consumed.has(action.into)) {
        out.push({
          code: 'schema-invalid',
          severity: 'warning',
          message: `analyze 的出参 result:${action.into} 没有任何一笔消费它 —— 白烧一次模型调用`,
          at,
        });
      }
    }

    if (step.view !== undefined && step.view !== '' && !templates.has(step.view)) {
      out.push({ code: 'schema-invalid', severity: 'error', message: `剧本引的视图模版不存在:${step.view}`, at });
    }
  }
  return out;
}
  • Step 4(R8):让全 auto 的步能声明「没有 guide」

这一条是 Task 7 的评审挖出来的,不在原计划里。 packages/contracts/src/manifest.ts:122 现在写着 guide: z.string().min(1) —— 必填且非空。而全 auto 的步按设计就没有 guide(引擎按 guide.trim() === '' 分「半 auto」与「全 auto」,见 Task 7 插入的那一段)。照现在的 schema, 第二份计划里四步全 auto 的活动会在装包那一刻被拒。

先写失败的测试,追加到 packages/contracts/src/__tests__/auto-recipe.test.ts 末尾:

import { planStepSchema } from '../manifest.ts';

describe('全 auto 的步可以没有 guide', () => {
  const base = { item: 'S1', guide: '', writes: [], reads: [], tools: [] };

  it('声明了 auto 就允许 guide 为空 —— 那一步压根不问模型', () => {
    const parsed = planStepSchema.safeParse({
      ...base,
      auto: { do: [{ kind: 'say', text: '办完了' }] },
    });
    expect(parsed.success).toBe(true);
  });

  it('没有 auto 又不写 guide → 拒 —— 「作者忘了写做法」这个老错照旧要拦', () => {
    const parsed = planStepSchema.safeParse(base);
    expect(parsed.success).toBe(false);
  });

  it('有 auto 但 guide 写了内容 → 照收(半 auto:确定的先做完,剩下的仍问模型)', () => {
    const parsed = planStepSchema.safeParse({
      ...base,
      guide: '把结论写实',
      auto: { do: [{ kind: 'say', text: 'x' }] },
    });
    expect(parsed.success).toBe(true);
  });
});

Run: pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts -t "全 auto" Expected: 第一条 FAIL(guide 被 .min(1) 拒),后两条 PASS。

改法:把 packages/contracts/src/manifest.ts:122 的

  guide: z.string().min(1),

改成

  guide: z.string(),

并在 planStepSchema 的 }); 之后接一段 superRefine(注意:planStepSchema 从此是 ZodEffects, 不能再 .extend() / .omit() —— 改之前先 grep -rn "planStepSchema\." packages apps 确认没有这类用法):

  .superRefine((step, ctx) => {
    /*
     * **全 auto 的步没有 guide 是正常形态**(2026-08-29):引擎按 `guide.trim() === ''`
     * 分「半 auto(确定的先做完,剩下的仍问模型)」与「全 auto(这一步一次都不问模型)」。
     * 而 `.min(1)` 让后者压根写不出来 —— 四步全 auto 的活动会在装包那一刻被拒。
     * 非空这条要求仍留给没有 auto 的步:**「作者忘了写做法」是个老错,照旧要拦**。
     */
    if (step.auto === undefined && step.guide.trim() === '') {
      ctx.addIssue({
        code: 'custom',
        path: ['guide'],
        message: '这一步的做法不能为空 —— 只有声明了 auto 配方的步才允许没有 guide',
      });
    }
  });

Run: pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts Expected: 全绿。

契约变更四步照走:pnpm api:check --update && git add api-report。

  • Step 4: 挂进 staticAudit

在 staticAudit(manifest, external) 函数体里,找到它 out.push(...loopFindings(manifest)) 那一行(或等价写法), 在它之后加一行:

  out.push(...autoRecipeFindings(manifest));
  • Step 6: 跑测试与全量门禁

Run:

pnpm exec vitest run apps/api/src/modules/activity-registry
pnpm exec vitest run packages/contracts
pnpm gates

Expected: 全绿。现有三个活动包(E04 / I01 / W05)必须照装不误 —— 它们没有 auto,这一段整个不进。

  • Step 7: 提交
git add apps/api/src/modules/activity-registry packages/contracts api-report
git commit -m "$(cat <<'EOF'
feat(activity-registry): 配方静态审 —— 写错地址在装包那一刻就拒

与 refersTo 目标检查同款:代码错是必然的错,而必然的错只有在装的时候被拦下才算好查。
台账地址 / 列名 / 模型名 / 视图模版 / submit 的验收清单逐条核;
analyze 的出参没人消费报 warning —— 那是白烧一次模型调用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 10: data-options —— 下拉的选项从台账现读

Files:

  • Modify: apps/api/src/modules/interaction/internal/domain/view-render.ts
  • Modify: apps/api/src/modules/interaction/__tests__/view-render.test.ts
  • Modify: docs/HTML报告生成规范.md(§4 绑定全表加一行)

Interfaces:

  • Consumes: 现有 resolveSlot / escapeHtml / displayValue

  • Produces: function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string,并由 renderView 在最前面调用

  • Step 1: 写失败的测试

追加到 apps/api/src/modules/interaction/__tests__/view-render.test.ts 末尾:

describe('data-options:下拉的选项从台账现读', () => {
  const scope = {
    'parts.chip_type': [
      { type_id: 1, type_name: 'DC-DC 降压稳压器' },
      { type_id: 2, type_name: 'LDO 线性稳压器' },
    ],
    chip_type_id: 2,
  };

  it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => {
    const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
    const out = renderView(html, scope);
    expect(out).toContain('<option value=""></option>');
    expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
    expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
  });

  it('选项文字要转义 —— 台账里的值是人填的', () => {
    const out = renderView(
      '<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
      { 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
    );
    expect(out).toContain('&lt;b&gt;坏&lt;/b&gt;');
    expect(out).not.toContain('<b>坏</b>');
  });

  it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
    const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
    expect(out).toContain('<option value=""></option>');
    expect(out).not.toContain('undefined');
  });

  it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
    const html =
      '<tbody data-rows="mine:a.b" data-edit="a.b">' +
      '<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
      '</tbody>';
    const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
    expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
    expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/modules/interaction/__tests__/view-render.test.ts -t "data-options" Expected: FAIL —— 出来的还是空 <select>

  • Step 3: 写实现

在 view-render.ts 里,renderView 之前插入:

/** `<select … data-options="域.对象" data-option-value="列" data-option-label="列">` */
const OPTIONS_SELECT = /<select\b([^>]*?)\bdata-options\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/select>/g;

/**
 * **下拉的选项从台账现读**(2026-08-29,spec §5.3)。
 *
 * 与 `data-rows` 同一层机制:**只取值,不判断**。地址写法也一致
 * (`域.对象` = 任务视图,`mine:域.对象` = 本单草稿)。
 *
 * 为什么必须是现读而不是写死在模版里:芯片类型是**人在件上现造**的,
 * 写死等于「只能选我出包那天就有的那几种」。
 *
 * **在行展开之前跑一遍**(`renderView` 的最内层):选项与行无关,一次铺好,
 * 行展开时整段复制进每一行;哪一个 `selected` 由 `selectCurrent` 按各行自己的值定。
 * 空选项一律留着 ——「还没选」得说得出来。
 */
export function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string {
  return html.replace(OPTIONS_SELECT, (_all, before: string, address: string, after: string) => {
    const raw = `${before}${after}`;
    const valueCol = /\bdata-option-value\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? 'value';
    const labelCol = /\bdata-option-label\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? valueCol;
    const attrs = raw
      .replace(/\s*\bdata-option-value\s*=\s*"[^"]*"/, '')
      .replace(/\s*\bdata-option-label\s*=\s*"[^"]*"/, '')
      .replace(/\s+/g, ' ')
      .trim();
    const rows = resolveSlot(scope, address);
    const list = Array.isArray(rows) ? rows : [];
    const options = list
      .map((row) => {
        const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
        const value = escapeHtml(displayValue(one[valueCol]));
        const label = escapeHtml(displayValue(one[labelCol]));
        return `<option value="${value}">${label}</option>`;
      })
      .join('');
    return `<select${attrs === '' ? '' : ` ${attrs}`}><option value=""></option>${options}</select>`;
  });
}

并把 renderView 改成:

/** 先铺下拉选项,再展开行,最后填标量槽 —— 顺序不能反 */
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
  return renderTemplate(renderRows(fillOptions(html, scope), scope), scope);
}

在 apps/api/src/modules/interaction/index.ts 的导出那一行加上 fillOptions:

export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
  • Step 4: 跑测试确认通过

Run: pnpm exec vitest run apps/api/src/modules/interaction Expected: 全绿(含新加的 4 条)

  • Step 5: 补规范

在 docs/HTML报告生成规范.md §4「模版语言参考(绑定全表)」的表格里,data-col 那一行之后加一行:

| `data-options="域.对象"`(配 `data-option-value` / `data-option-label`) | `<select>` | **选项从台账现读**:一行一个 `<option>`,`value` 取值列、显示取名列,前面固定留一个空选项(「还没选」得说得出来)。地址写法与 `data-rows` 一致。选中哪一个仍由 `selectCurrent` 按这一行的值定 —— 模版这一侧照旧只取值不判断 |
  • Step 6: 提交
git add apps/api/src/modules/interaction docs/HTML报告生成规范.md
git commit -m "$(cat <<'EOF'
feat(interaction): data-options —— 下拉的选项从台账现读

芯片类型是人在件上现造的,写死在模版里等于「只能选出包那天就有的那几种」。
与 data-rows 同一层机制:只取值不判断,选中哪一个仍由 selectCurrent 按行内的值定。
空选项一律留着 ——「还没选」得说得出来。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 11: 件上「保存」之后 tick 一次

Files:

  • Create: apps/api/src/app/save-wake.ts
  • Create: apps/api/src/app/__tests__/save-wake.test.ts
  • Modify: apps/api/src/app/routes/desk.ts:1364-1365(saveTableRoute 处理器里那两行)

Interfaces:

  • Consumes: 现有 container.views.repush(taskId) 与 container.task.wake(taskId)

  • Produces: function afterSave(deps: { views: { repush(taskId: TaskId): Promise<unknown> }; task: { wake(taskId: TaskId): Promise<unknown> } }, taskId: TaskId, rows: number): Promise<void>

  • Step 1: 写失败的测试

// apps/api/src/app/__tests__/save-wake.test.ts
import { describe, expect, it } from 'vitest';
import { taskIdSchema } from '@contracts';
import { afterSave } from '../save-wake.ts';

const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');

function spy() {
  const calls: string[] = [];
  return {
    calls,
    deps: {
      views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
      task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
    },
  };
}

describe('件上保存之后', () => {
  it('真落下了行:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
    const { calls, deps } = spy();
    await afterSave(deps, TASK, 3);
    expect(calls).toEqual(['repush', 'wake']);
  });

  it('一行都没落下:什么也不做 —— 没变的东西不值得叫醒谁', async () => {
    const { calls, deps } = spy();
    await afterSave(deps, TASK, 0);
    expect(calls).toEqual([]);
  });

  it('叫醒失败不抬手:保存本身是成功的,别因为叫不醒把 200 变成 500', async () => {
    const calls: string[] = [];
    const deps = {
      views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
      task: { wake: () => Promise.reject(new Error('库连不上')) },
    };
    await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
    expect(calls).toEqual(['repush']);
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/app/__tests__/save-wake.test.ts Expected: FAIL —— Failed to resolve import "../save-wake.ts"

  • Step 3: 写实现
// apps/api/src/app/save-wake.ts
/**
 * **件上按了保存之后**(2026-08-29,spec §5.4)。
 *
 * 两件事按序:
 *   ① 重推件 —— 数据变了,算出来的那些(红带缺几格 / 分布条 / 去向)得重新求值;
 *   ② **叫醒任务** —— auto 步的配方这才有机会按最新的草稿把该铺的行铺出来。
 *      人挑了芯片类型、按了保存,参数格就在这一下之后出现。
 *
 * 从前只有两条回传路由调 `wake`;保存不是回传,于是「保存即铺格」永远等不到那一拍。
 * **叫不醒不抬手**:保存本身是成功的,不该因为叫不醒把 200 变成 500 ——
 * 下一次推进器扫到它照样会跑。
 */
import type { TaskId } from '@contracts';

export async function afterSave(
  deps: {
    readonly views: { repush(taskId: TaskId): Promise<unknown> };
    readonly task: { wake(taskId: TaskId): Promise<unknown> };
  },
  taskId: TaskId,
  rows: number,
): Promise<void> {
  if (rows <= 0) return;
  await deps.views.repush(taskId);
  await deps.task.wake(taskId).catch(() => false);
}
  • Step 4: 接进路由

在 apps/api/src/app/routes/desk.ts 的 saveTableRoute 处理器里,把这两行:

    // 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
    if (saved.rows > 0) await container.views.repush(taskId);

换成:

    // 数据变了,件跟着变;并叫醒任务 —— auto 步的配方这才有机会按最新的草稿铺格
    await afterSave(container, taskId, saved.rows);

并在该文件顶部 import 段加:

import { afterSave } from '../save-wake.ts';
  • Step 5: 跑测试与全量门禁

Run:

pnpm exec vitest run apps/api/src/app/__tests__/save-wake.test.ts
pnpm gates

Expected: 全绿。

  • Step 6: 提交
git add apps/api/src/app/save-wake.ts apps/api/src/app/__tests__/save-wake.test.ts apps/api/src/app/routes/desk.ts
git commit -m "$(cat <<'EOF'
feat(desk): 件上保存之后叫醒任务 —— auto 步的配方才有机会按最新草稿铺格

从前只有两条回传路由调 wake;保存不是回传,于是「保存即铺格」永远等不到那一拍。
叫不醒不抬手:保存本身是成功的,不该因为叫不醒把 200 变成 500。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 12: 工作日志把配方做的事归到系统名下

Files:

  • Modify: apps/api/src/modules/memory/internal/domain/task-log.ts(actorOf 开头加一条)
  • Modify: apps/api/src/modules/memory/__tests__/(测 task-log 的那个文件,用 grep -rln "actorOf\|taskLog" apps/api/src/modules/memory/__tests__/ 找它)

Interfaces:

  • Consumes: Task 4 起每一笔配方留痕都带的 detail.auto = '<步名>#<第几笔>'

  • Produces: 无新导出 —— 改的是 actorOf 的判定

  • Step 1: 写失败的测试

追加到 task-log 的测试文件末尾:

describe('日志三色:配方做的事归系统', () => {
  it('带 detail.auto 的那几行是**系统**做的 —— 既不是 AI 判的,也不是人办的', () => {
    const lines = toLogLines([
      { kind: 'result', title: '芯片主数据:写下 2 行', detail: { auto: 'S1#3' }, at: '2026-08-29T00:00:00Z' },
    ] as never);
    expect(lines[0]?.who).toBe('system');
    expect(lines[0]?.actor).toBe('系统');
  });

  it('没有 detail.auto 的 result 照旧记在 AI 名下 —— 老行为一个字不变', () => {
    const lines = toLogLines([{ kind: 'result', title: '我判齐了', detail: {}, at: '2026-08-29T00:00:00Z' }] as never);
    expect(lines[0]?.who).toBe('agent');
  });
});

toLogLines 是该文件里已经在用的入口函数名;若它叫别的, 用 grep -n "export function" apps/api/src/modules/memory/internal/domain/task-log.ts 找那个把留痕转成日志行的导出。

  • Step 2: 跑一遍,确认第一条失败

Run: pnpm exec vitest run apps/api/src/modules/memory -t "配方做的事归系统" Expected: 第一条 FAIL(who 是 agent),第二条 PASS

  • Step 3: 在 actorOf 开头加一条

在 task-log.ts 的 function actorOf(e: MemoryEntry) 函数体第一行(const by = … 之前)插入:

  /*
   * **配方做的事归系统**(2026-08-29,ADR-014)。自主率的分法要认第三档:
   * 它既不是 AI 判出来的,也不是人办的 —— 是系统按声明做的。
   * 不加这一条的话,`kind: 'result'` 会掉进 `default` 分支被记在「任务助手」名下,
   * 日志上看着像 AI 干的 —— **界面不撒谎**。
   */
  if (typeof e.detail?.['auto'] === 'string') return { actor: '系统', actorId: undefined, who: 'system' as const };
  • Step 4: 跑测试与全量门禁

Run:

pnpm exec vitest run apps/api/src/modules/memory
pnpm gates

Expected: 全绿。

  • Step 5: 提交
git add apps/api/src/modules/memory
git commit -m "$(cat <<'EOF'
fix(memory): 工作日志把配方做的事归系统 —— 它不是 AI 判的,也不是人办的

不加这一条,配方的留痕会掉进 default 分支记在「任务助手」名下,日志上看着像 AI 干的。
自主率的分法要认第三档:确定的事交系统,判断交 AI,责任交人。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 13: 收口 —— 文档、欠账、全量回归

Files:

  • Modify: docs/活动开发规范.md(新增一节:确定性步骤怎么写)
  • Modify: docs/adr/ADR-014-deterministic-steps.md(状态 提案 → 已实现,并记两处改口)
  • Modify: docs/debt.md(记两笔新欠账)
  • Modify: CLAUDE.md(现状快照加一条)

Interfaces:

  • Consumes: Task 1-11 全部

  • Produces: 无代码产物 —— 这一条的交付物是「下一个人看得懂怎么用它」

  • Step 1: 跑一遍完整门禁,拿到真实的绿

Run: pnpm gates Expected: 六道全绿。这是本计划的验收线:typecheck / lint / depcruise / api:check / test 一道不许跳。

  • Step 2: 确认三个现有活动包一个字没改

Run: git diff --stat mvp-foundation...HEAD -- activities/ Expected: 空(三点写法 = 从两支的分叉点比起)。机制是加法,老活动的行为必须逐字不变。 若这条命令回了东西,说明你改到了活动包 —— 回去看是哪一步改的。

  • Step 3: 在活动开发规范里补一节

在 docs/活动开发规范.md 的 §4(剧本 steps)之后新增 §4.6,正文写这些(照该文档既有的语气):

## 4.6 确定性步骤:这一步系统自己做(2026-08-29)

一步要么确定,要么不是。**不做「一半 auto」** —— 判断点先从那一步里切出去。

`PlanStep.auto` 是一份**声明式配方**,五个动词按序执行:

| 动词 | 做什么 |
|---|---|
| `project` | 从某个来源的每一行投影出台账行,落草稿层。带 `prune` 时,这一笔投影出来的行**就是这张表在本单草稿里的全部** |
| `analyze` | **一次结构化 AI 调用**:系统组装入参、声明出参 schema、系统把结果落表。不给工具、不给记忆、不多轮 |
| `card` | 发一张卡(执行工单 / 数据工单 / 审核卡)。**至多一张**,发过就不再发 |
| `submit` | 提交放行(只许出现在声明了人工验收清单的那一步) |
| `say` | 一句给人看的交代 |

地址只有四种:`域.对象`(任务视图 = 正式 + 本单草稿叠加,**默认该用的那一种**)· `mine:域.对象` ·
`in:入参数组` · `result:xxx`(本拍 analyze 的出参)。

**四条纪律**(每一条都是踩出来的):

1. **行由确定的那一侧决定,不由 AI 决定。** 明细表要按类型字典 `each` 出来,AI 的出参只是拿主键
   `lookup` 的一张查表 —— 它漏一条,那一格就是空,`required` 立刻变成 `_missing > 0`,闸与放行门当场拦住。
   写成 `from each(AI 出参)` 的话,它少给两条就少两行,而每一行看着都合规。
2. **判据不许编一个数不出来的数。** 「本类型有几个字段」不是基座数得出来的数(`_rows` 数的是**本单写下的**行);
   行数对账交给 `prune` 的结构保证,别写进 `doneWhen`。
3. **每一笔之前刷读数。** 后一笔常要数前一笔刚写下的行。
4. **出错交人,不重试、不退回模型。** 配方抛错是包的 bug;退回模型就是让它去凑。
  • Step 4: 把 ADR-014 的状态改掉

把 docs/adr/ADR-014-deterministic-steps.md 第 3 行:

状态:**提案**(2026-08-28,待用户定)

改成:

状态:**已实现**(2026-08-29;设计见 `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §5)

并在 §8「不做什么」那一节末尾加一段:

> **两处改口**(2026-08-29,ACT-WORK-05 重写时用户定):`submit` 与**发决策卡**这两条禁令撤掉。
> 它们原本是**假设还有 agent 在场**时的保守话;而全程无 agent 的活动**没有别人来干这两件事**,
> 不开口最后两步就没人做。护栏没松:`submit` 只许出现在声明了人工验收清单的那一步,
> 提交门(判据全绿)照旧把关 —— **系统提交不比模型提交宽松**;决策卡只在 `variant:'review'` 时
> 放宽到单选项(它不是岔路口,是一道签字关)。
  • Step 5: 记两笔欠账

在 docs/debt.md 末尾加:

- **配方没有 `drop` 动词**(2026-08-29):撤行只能靠 `project` 的 `prune`(「这一笔投影出来的就是全部」)。
  要撤一行而不重投整张表的场合,现在写不出来。等真出现第二个用例再加。
- **配方的 `analyze` 不计入 `stalledTurns`,也不进单拍工具缝**(2026-08-29):它烧 token、计预算,
  但不算「被拒」——熔断看不见它。一份写坏的 `instruction` 会每拍烧一次而不触发熔断;
  目前靠「auto 步发了卡就收拍」封住(analyze 那一步不发卡,`doneWhen` 不成立就交人),
  真出现空转再补一道计数。
- **治理台的活动详情还摆不出「这一步系统会做什么」**(2026-08-29):配方是数据,渲染得出来 ——
  这是相对提示词白赚的一件,但要动前端,不在这一轮(后端已经把 `auto` 随清单存进库了)。
  • Step 6: 在 CLAUDE.md 的现状快照里加一条

在 ## 现状快照 列表末尾加(照该文件既有的行文):

- **确定性步骤已落地**(2026-08-29,ADR-014):`PlanStep.auto` 声明式配方,五个动词
  (`project` / `analyze` / `card` / `submit` / `say`)、四种地址、嵌套两层封顶;引擎在拍首跑,
  **发了卡就收拍(零 token)**,有 `guide` 的照常问模型。`analyze` 是**一次结构化 AI 调用** ——
  系统组装入参、声明出参 schema、系统落表,**不给工具不给记忆不多轮**。
  三条纪律进了规范 §4.6:**行由确定的那一侧决定不由 AI 决定** / 判据不许编数不出来的数 /
  每一笔之前刷读数。静态审在装包时把地址、列名、模型名、视图、submit 的验收清单逐条核死。
  配套四件:`data-options`(下拉选项从台账现读)· 件上保存后 `wake`(「保存即铺格」)·
  审核卡放宽到单选项 · 卡答复即收步。**E04 / I01 / W05 一个字没改** —— 它们的剧本里没有 `auto`。
  • Step 7: 最后一遍门禁并提交
pnpm gates
git add docs CLAUDE.md
git commit -m "$(cat <<'EOF'
docs: 确定性步骤落地 —— 规范 §4.6、ADR-014 转已实现、两笔欠账

ADR-014 §8 的两条禁令(不 submit、不发决策卡)撤掉并写明理由:
那是假设还有 agent 在场时的保守话,全程无 agent 的活动没有别人来干这两件事。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

验收线

这份计划做完,下面每一条都要以退出码或真实输出验过,不许以 echo 验:

  1. pnpm gates 六道全绿。
  2. git diff --stat mvp-foundation -- activities/ 为空 —— 三个现有活动包一个字没改。
  3. 新增单测:契约 4 + 表达式 18 + 数据源 6 + 执行器 16 + analyze 网关 3 + liveStepOf 5 + turn-runner 接线 7 + 静态审 6 + data-options 4 + 保存唤醒 3 + 日志归属 2 = 74 条。
  4. PlanStep.auto 写坏地址 / 列名 / 模型名 / 视图名 / 缺验收清单的 submit,pnpm activities:install 时装不进去。

这份计划不产出任何可跑的业务活动 —— 机制的第一个真实用户是第二份计划(W05 重写)。 它是刻意的:机制先在纯函数与假端口上钉死,再去接真活动;两件事搅在一起,出了问题分不清是机制还是活动。