# 确定性步骤(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) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet ``` --- ## 前置:先开一条 feature 分支 当前 HEAD 在 **主支 `mvp-foundation`** 上。这份计划有十三次提交,不许直接堆在主支上。 ```bash 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: 写失败的测试** ```ts // 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: 写契约** ```ts // 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>; readonly take: string; readonly else?: AutoExpr; } /** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */ | { readonly when: string; readonly then: AutoExpr; readonly else: AutoExpr }; export const autoExprSchema: z.ZodType = 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>[] } | { readonly each: string; readonly where?: Readonly>; /** 第二层,**到此为止** —— 三层以上就别 auto 了 */ readonly then?: { readonly each: string; readonly where?: Readonly> }; }; export const autoSourceSchema: z.ZodType = 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>; } | { readonly kind: 'analyze'; readonly when?: string; /** 活动声明过的模型名 */ readonly model: string; /** 出参落进拍内作用域 `result:`,供后面的 project 用 `each`/`lookup` 消费 */ readonly into: string; /** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */ readonly instruction: string; /** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */ readonly input: { readonly from: AutoSource; readonly fields: Readonly> }; /** 出参 JSON Schema —— 顶层必须是 object */ readonly output: Readonly>; } | { 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 = 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>; /** 按序执行 */ readonly do: readonly AutoAction[]; }; export const autoRecipeSchema: z.ZodType = 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` 那一行**之后**、`});` 之前插入: ```ts /** * **这一步系统按声明自己做**(2026-08-29,ADR-014 / spec §5)。 * * 引擎在这一步的每一拍拍首跑配方:发了卡就收拍(零 token);没发卡且这一步没有 `guide` * 就重求 `doneWhen` 收步;有 `guide` 的照常问模型(半 auto)。 * **老活动一个字不用改** —— 不写就是不写,不是缺件。 */ auto: autoRecipeSchema.optional(), ``` 并在该文件顶部的 import 段加: ```ts import { autoRecipeSchema } from './auto-recipe.ts'; ``` - [ ] **Step 5: 转出契约** 在 `packages/contracts/src/index.ts` 里加一行(按文件里既有的字母序位置放): ```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: ```bash pnpm api:check --update && git add api-report pnpm gates ``` Expected: 全绿。`api:check` 若报「契约变了没入库」,说明上一条没跑或没 `git add`。 - [ ] **Step 8: 提交** ```bash 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) 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>` - `type ExprCtx = { scope: Readonly>; lets: Readonly>; row?: Row; outer?: Row; computed?: Row; condition: (expr: string, scope: Readonly>) => boolean }` - `function rowsAt(scope: Readonly>, address: string): readonly Row[]` - `function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown` - `function evalText(expr: AutoExpr, ctx: ExprCtx): string` - [ ] **Step 1: 写失败的测试** ```ts // 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>): 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: 写实现** ```ts // 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>; export type ExprCtx = { /** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */ readonly scope: Readonly>; /** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */ readonly lets: Readonly>; readonly row?: Row; readonly outer?: Row; /** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */ readonly computed?: Row; readonly condition: (expr: string, scope: Readonly>) => boolean; }; /** * 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。 * * **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。 */ export function rowsAt(scope: Readonly>, 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: 提交** ```bash 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) 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: 写失败的测试** ```ts // 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) => ({ 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: 写实现** ```ts // 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> | 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 = {}; 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: 提交** ```bash 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) 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>; label: string }): Promise<{ readonly object: Readonly> }> }` - `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 }` - [ ] **Step 1: 先把 `AnalyzeGateway` 加进 port.ts** 在 `apps/api/src/modules/loop-engine/port.ts` 末尾(`ViewGateway` 之后)加: ```ts /** * **一次结构化 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>; /** 归因:`<步名>#<第几笔>` */ readonly label: string; }): Promise<{ readonly object: Readonly> }>; }; ``` 并把 `AnalyzeGateway` 加进 `TurnRunnerDeps`(`apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts` 第 43 行起的那个类型)最后一行之前: ```ts readonly analyze: AnalyzeGateway; ``` 以及在该文件顶部的 import 里补上 `AnalyzeGateway`(与 `AgentPort` 同一行 import)。 这一步会让**两个地方**少一个必填属性,两处都要在本任务里补上,否则 `typecheck` 当场红: **① 装配层占位**(`apps/api/src/app/wiring/engines.ts`,Task 5 换成真实现): ```ts 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,` 之后加一行): ```ts analyze: { run: async () => ({ object: {} }) } as never, ``` 补完先跑一遍确认既有用例没被碰坏: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts` - [ ] **Step 2: 写失败的测试** ```ts // 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 = {}) { const drafts = new Map; patch: Record }>(); const notes: string[] = []; const view = (): Record => { const mine: Record = {}; 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; patch: Record }) => { 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 }) => { 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>) => { 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 留占位抛错)** ```ts // 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>): string => Object.values(key).join('|'); export function createAutoRunner(deps: AutoRunnerDeps): { run(recipe: AutoRecipe, ctx: AutoContext): Promise; } { return { async run(recipe, ctx) { const writable = await deps.data.writable(ctx.taskId); const results: Record = {}; let scope: Record = { ...(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 = {}; 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(); let localWrote = 0; for (const { row, outer } of expandSource(action.from, base)) { const computed: Record = {}; for (const [name, expr] of Object.entries(action.fields)) { computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed }); } const key: Record = {}; for (const column of decl.keys) key[column] = String(computed[column] ?? ''); /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */ if (decl.keys.some((column) => key[column] === '')) continue; const patch: Record = {}; 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 = {}; 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: 提交** ```bash 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) 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`(`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` 里这一行: ```ts function harness(seed: Record = {}) { ``` 改成: ```ts type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record }> }; function harness(seed: Record = {}, override: { analyze?: AnalyzeStub } = {}) { ``` 并把 `const deps = { ... }` 里的这一行: ```ts analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, ``` 改成: ```ts analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, ``` - [ ] **Step 2: 写 analyze 的三个失败用例** 在 `auto-runner.test.ts` 末尾追加: ```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` 之前插入: ```ts if (action.kind === 'analyze') { const payload = expandSource(action.input.from, base).map(({ row, outer }) => { const item: Record = {}; 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: 写网关的失败测试** ```ts // 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> = []; const gateway = createAnalyzeGateway({ generate: (c: Record) => { 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: 写网关** ```ts // 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): 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, 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 }; } 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 }; } throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`); }, }; } ``` - [ ] **Step 9: 换掉 engines.ts 里的占位** 在 `apps/api/src/app/wiring/engines.ts` 里把 Task 4 加的那行占位 ```ts analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) }, ``` 换成: ```ts analyze: createAnalyzeGateway(deps.llm), ``` 并在该文件顶部 import 段加: ```ts import { createAnalyzeGateway } from './analyze-gateway.ts'; ``` > `deps.llm` 就是装配层已有的 `ModelGateway`。若该变量在你手上叫别的名字, > 用 `grep -n "ModelGateway" apps/api/src/app/wiring/engines.ts` 找它。 - [ ] **Step 10: 跑测试与全量门禁** Run: ```bash pnpm exec vitest run apps/api/src/app/__tests__/analyze-gateway.test.ts pnpm gates ``` Expected: 前者 3 个用例 PASS;`pnpm gates` 全绿。 - [ ] **Step 11: 提交** ```bash 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) 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` 末尾追加: ```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()` 里那两个发卡端口改成**记录入参**: ```ts const cards = { issued: [] as Array> }; ``` (放在 `const notes: string[] = [];` 旁边),并把 `deps.cards` 里这两行: ```ts issue: () => Promise.resolve({ id: 'card-1' }), decide: () => Promise.resolve({ id: 'card-2' }), ``` 改成: ```ts issue: (i: Record) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); }, decide: (i: Record) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); }, ``` 最后把 `harness` 的返回值加上 `cards`: ```ts 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` 之前插入: ```ts 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` 的名字照该文件既有的风格): ```ts 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` 那一行: ```ts if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' }); ``` 改成: ```ts /* * **审核卡是例外**(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: ```bash pnpm exec vitest run apps/api/src/modules/human-tool/__tests__/ pnpm gates ``` Expected: 全绿。 - [ ] **Step 9: 提交** ```bash 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) 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(steps: readonly S[], settledIds: ReadonlySet, onStage: readonly { id: string }[]): S | undefined` - [ ] **Step 1: 写 `liveStepOf` 的失败测试** ```ts // 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`** ```ts // apps/api/src/modules/loop-engine/internal/domain/live-step.ts /** * **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。 * * 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**, * 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了, * 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。 */ export function liveStepOf( steps: readonly S[], settledIds: ReadonlySet, 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 行): ```ts const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries] .filter((e) => !settledIds.has(e.id)) .map((e) => stepOf(e.id)) .find((x) => x !== undefined); ``` 替换为: ```ts const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]); ``` 并在文件顶部 import 段加: ```ts 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 行): ```ts const waiting = await deps.cards.unanswered(taskId); if (waiting.length > 0) { await deps.memory.append({ ``` 在 `if (waiting.length > 0) {` 之后、`await deps.memory.append({` 之前插入: ```ts /* * **有卡在等人,配方照样跑 —— 只算、只写**(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 行)**之前**插入: ```ts /* * **配方段**(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 { ... }` 之前)加: ```ts const autoRunner = createAutoRunner({ data: deps.data, cards: deps.cards, memory: deps.memory, analyze: deps.analyze, sfeel: deps.sfeel, }); ``` 并在文件顶部 import 段加: ```ts import { createAutoRunner } from './auto-runner.ts'; ``` - [ ] **Step 10: 确认测试脚手架的 `analyze` 桩已经在** Task 4 已经在 `turn-runner-acceptance.test.ts` 的 `const deps: TurnRunnerDeps = {` 里加过这一行: ```ts 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` 末尾: ```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` 的不变量清单里加三条: ```markdown - **auto 步不问模型**:`PlanStep.auto` 存在时,拍首先跑配方;发了卡 / submit 就收拍(零 token)。 有 `guide` 的照常问模型(半 auto);两者都没有且 `doneWhen` 不成立 → 交人(配方没做到位,是包的 bug)。 - **有未答的卡在场时,配方只跑 `project` / `say`** —— 只算、只写,不发新卡、不 submit、不调 AI。 - **配方每一笔之前刷新任务视图**:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。 ``` - [ ] **Step 15: 提交** ```bash 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) 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 步:配方在拍首跑', …)` 里: ```ts 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` **之前**加: ```ts /** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */ 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() === '') { … }` **之前**插入: ```ts /* * **卡答过了就收步**(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`: ```ts 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: 提交** ```bash 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) 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 = " ` 找它): ```ts 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` **之后**插入: ```ts /** * **配方静态审**(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; 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; 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)) 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(); 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` 末尾: ```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` 的 ```ts guide: z.string().min(1), ``` 改成 ```ts guide: z.string(), ``` 并在 `planStepSchema` 的 `});` 之后接一段 `superRefine`(注意:`planStepSchema` 从此是 `ZodEffects`, 不能再 `.extend()` / `.omit()` —— 改之前先 `grep -rn "planStepSchema\." packages apps` 确认没有这类用法): ```ts .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))` 那一行(或等价写法), 在它之后加一行: ```ts out.push(...autoRecipeFindings(manifest)); ``` - [ ] **Step 6: 跑测试与全量门禁** Run: ```bash 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: 提交** ```bash 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) 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>): string`,并由 `renderView` 在**最前面**调用 - [ ] **Step 1: 写失败的测试** 追加到 `apps/api/src/modules/interaction/__tests__/view-render.test.ts` 末尾: ```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 = ''; const out = renderView(html, scope); expect(out).toContain(''); expect(out).toContain(''); expect(out).toContain(''); }); it('选项文字要转义 —— 台账里的值是人填的', () => { const out = renderView( '', { 't.t': [{ v: 'x', l: '坏' }], c: '' }, ); expect(out).toContain('<b>坏</b>'); expect(out).not.toContain('坏'); }); it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => { const out = renderView('', {}); expect(out).toContain(''); expect(out).not.toContain('undefined'); }); it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => { const html = '' + '' + ''; const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] }); expect(out.match(/