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
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): unknownfunction 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('<b>坏</b>');
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 验:
pnpm gates六道全绿。git diff --stat mvp-foundation -- activities/为空 —— 三个现有活动包一个字没改。- 新增单测:契约 4 + 表达式 18 + 数据源 6 + 执行器 16 + analyze 网关 3 + liveStepOf 5 + turn-runner 接线 7 + 静态审 6 +
data-options4 + 保存唤醒 3 + 日志归属 2 = 74 条。 PlanStep.auto写坏地址 / 列名 / 模型名 / 视图名 / 缺验收清单的 submit,pnpm activities:install时装不进去。
这份计划不产出任何可跑的业务活动 —— 机制的第一个真实用户是第二份计划(W05 重写)。 它是刻意的:机制先在纯函数与假端口上钉死,再去接真活动;两件事搅在一起,出了问题分不清是机制还是活动。