计划原稿让 Task 8/9 复用「Task 7 已经加进去的 viewOf / ASSESSMENT_STEPS」, 而 Task 7 的实现者核过文件后用的是既有的 viewHtml(:38 本地定义)与 manifest.assets.businessModel.steps —— 照原稿写会去找两个不存在的东西。 这个文件刻意走清单而不是 case-model.ts 的源常量:源常量对得上而清单里漏装, 正是它要拦的那类错。ASSESSMENT_STEPS 是真导出,但那是 steps.test.ts 的用法。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
124 KiB
ACT-WORK-05 差异评估 · 活动重写 实施计划(第二份)
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: 把 activities/part-substitution-assessment 从「九步、两枚工具、全程 agent 驱动」重写成「四步、零工具、零 guide、AI 出场恰好一次」—— 每一步由引擎按声明式配方确定性地做完。
Architecture: 活动包只是数据(清单 + 案例模型 + HTML 模版),平台不执行它的代码。四步各写一份 PlanStep.auto 配方(五动词);唯一的 AI 出场是 S2 的一笔 analyze —— 系统把两颗料的核心参数逐条组装好交给它,它只答「这一条算什么、为什么」,值是多少一个字都不重述。推荐等级、风险、摘要、验证要求全由规则与模板串算,不让 AI 编。
Tech Stack: TypeScript(exactOptionalPropertyTypes)· zod 契约 · vitest · S-FEEL 判据 · 无脚本沙箱 HTML 模版
Spec: docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md —— 本计划实现它的 §3 / §4 / §6 / §7 / §8 / §9 / §10(§5 平台机制已在 feat/deterministic-steps 上落地并通过全支终审)
前置(已完成,可直接用)
PlanStep.auto配方:五动词project/analyze/card/submit/say;八式表达式;四种地址;嵌套两层封顶guide允许为空当且仅当这一步声明了auto- 卡答复即收步(前提:无
doneWhen+ 卡发过 + 配方会发卡 + 这一步没有 guide) - 装包静态审:台账地址 / 列名 / 模型名 / 视图名 /
submit要有人工验收清单 /when的 S-FEEL /project.fields必须含目标表主键列 /card必须有basis/prune与humanWrites同表报 warn - 写配方怎么写:《活动开发规范》§4.6 —— 动手前先读它
Global Constraints
- 五道门禁以退出码验,不许接管道(
cmd | tail的退出码是tail的):pnpm gates。 - 契约变更四步:改
packages/contracts→ depcruise 反查下游 → 同步 CONTRACT.md →pnpm api:check --update && git add api-report。 - 禁止清单:不许
any/as unknown as/@ts-ignore;生产代码不许as never(测试文件里是本仓既有做法)。 - 活动包只准 import
@sdk/@contracts/@sfeel—— depcruise 门禁会查。 - 机检只读基座数:判据的任何一边都不许是活动自己写的汇总列。这条在本仓被违反过六次,每次都是同一种事故。
- 判据不许编一个数不出来的数:
_rows/_missing/ tally /_distinct_x数的都是本单草稿写下的行,不是整张表。「本类型有几个字段」不是基座数,别写进doneWhen。 - 触达世界的项不给
doneWhen—— 给了等于给引擎一把替人签字的钥匙。 - 模版不许算、不许判:只取值;判断留在样式表(
data-class-of)与基座。 - 注释用中文,写「为什么这么做 / 踩过什么坑」,不写「这是什么」。
- 反例一律不引原话 —— 关思考的模型不读「别说」的否定框,只认框里现成的句子。要引原话就引正例。
- 安装恒新增:改了内容必须升
packageVersion,同版重装是 no-op(静默丢失,踩过)。装完看库,不看脚本的话。 - 试跑一律用 AI 服务账号,绝不派到用户工作台;测完
pnpm --filter @app/api reset-runs。 - 不要跑
./stable.sh update(稳定版与开发共用jcai_wos库,别动它)。 - 提交信息中文,末尾附:
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
前置:分支
第一份计划的成果在 feat/deterministic-steps 上(38 笔,未合并)。本计划建在它之上:
git switch feat/deterministic-steps # 若已在别处,先确认它是 HEAD
git switch -c feat/w05-rewrite
做完由主支发起合并(先合机制那条,再合这条),不要反过来把主支合进 feature。
文件结构
| 文件 | 职责 | 动作 |
|---|---|---|
apps/api/src/app/seed-mvp-data.ts |
平台基线:物理表 + 种子数据 | 改:加两张新表的物化 + 类型/字段/LDO 料的种子行 |
activities/part-substitution-assessment/src/constants.ts |
域名、规则版本 | 改:退役两个工具名常量 |
.../src/manifest.ts |
清单:契约 / 台账声明 / 判据 / 样例 / 视图 | 重写 |
.../src/case-model.ts |
CMMN + 说明书 + 四步剧本(含配方)+ 阶段 | 重写 |
.../src/views/target-style.ts |
目标件抽出来的样式表 | 改:保留可复用的,删无主的 |
.../src/views/report.ts |
三张件 | 重写 |
.../src/views/ledger-board.ts |
运营中心看板 | 改:重绑到新表 |
.../src/skills.ts |
技能戳(给模型看的) | 删:全程无 agent,它没有消费方 |
.../__tests__/activity.test.ts |
清单合规 / 授权面对齐 | 重写 |
.../__tests__/steps.test.ts |
剧本与配方口径钉死 | 重写 |
tools/e2e-a2.mjs |
e2e 套件 | 改:SUITES.w05(约 550 行起)重写成五案例 |
apps/api/src/app/tools/first-party.ts |
一方工具 | 改:两枚工具退役并留墓志铭 |
Task 1: 两张新字典表 —— 物化 + 种子
Files:
- Modify:
apps/api/src/app/seed-mvp-data.ts - Test:
apps/api/src/app/__tests__/seed-mvp-data.test.ts
Interfaces:
-
Produces: 物理表
parts__chip_type(type_idinteger 主键 ·type_name·description)与parts__type_param(type_id+param_name复合主键 ·param_desc·constraint_type·param_unit);parts.profile追加chip_type_idinteger 列;种子数据:两个类型、七 + 五个核心参数字段、三颗料的chip_type_id、一颗新 LDO 料AMS1117-3.3及其参数行 -
Step 1: 先读现状,别猜
Run:
grep -n "domain: 'parts'" apps/api/src/app/seed-mvp-data.ts | head
sed -n '140,200p' apps/api/src/app/seed-mvp-data.ts
看清两件事:①表的物化清单长什么样(f(name, type, label, primaryKey) 帮手、address: { domain, object } 的结构);②种子行长什么样({ address, key, values })。照它的形状写,别另造一套。
- Step 2: 写失败的测试
追加到 apps/api/src/app/__tests__/seed-mvp-data.test.ts 末尾。帮手名已查实:该文件从
../seed-mvp-data.ts 引 MASTER_DOMAINS / MASTER_OBJECTS / MASTER_SAMPLE_ROWS,先在文件里加两个局部帮手:
const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object);
const seedRowsOf = (domain: string, object: string) =>
MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object);
describe('芯片类型字典(W05 重写)', () => {
it('两张新表都物化了,主键形状对', () => {
const chipType = tableOf('parts', 'chip_type');
expect(chipType?.fields.find((f) => f.name === 'type_id')).toMatchObject({ type: 'integer', primaryKey: true });
expect(chipType?.fields.map((f) => f.name)).toEqual(expect.arrayContaining(['type_id', 'type_name', 'description']));
const typeParam = tableOf('parts', 'type_param');
/* 复合主键:一个类型下多个参数名 —— 主键少一半,同一类型的第二个参数就会覆盖第一个 */
expect(typeParam?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
expect(typeParam?.fields.map((f) => f.name)).toEqual(
expect.arrayContaining(['type_id', 'param_name', 'param_desc', 'constraint_type', 'param_unit']),
);
});
it('P-08 上有 chip_type_id,类型与字典的主键对得上(integer)', () => {
expect(tableOf('parts', 'profile')?.fields.find((f) => f.name === 'chip_type_id')).toMatchObject({ type: 'integer' });
});
it('种子:两个类型,DC-DC 七个核心参数字段、LDO 五个', () => {
const types = seedRowsOf('parts', 'chip_type');
expect(types.map((r) => r.values['type_name']).sort()).toEqual(['DC-DC 降压稳压器', 'LDO 线性稳压器']);
const dcdc = types.find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
const params = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === dcdc);
expect(params.map((r) => r.values['param_name'])).toEqual([
'输入电压范围', '输出电压', '输出电流', '开关频率', '静态电流', '工作温度', '使能阈值',
]);
/* 约束类型搬到字典上了 —— 同一个参数在两颗料上标法不一致这件事从结构上消失 */
expect(params.find((r) => r.values['param_name'] === '静态电流')?.values['constraint_type']).toBe('soft');
expect(params.find((r) => r.values['param_name'] === '输入电压范围')?.values['constraint_type']).toBe('hard');
/* 每个字段都要有说明 —— AI 分析时读的就是它 */
expect(params.every((r) => String(r.values['param_desc'] ?? '') !== '')).toBe(true);
const ldo = types.find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
expect(seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo)).toHaveLength(5);
});
it('三颗既有料都归了 DC-DC 类型,新播一颗 LDO 供「类型不一致」那条用例用', () => {
const dcdc = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
const parts = seedRowsOf('parts', 'profile');
for (const pn of ['LM2596S-5.0', 'LM2596S-ADJ']) {
expect(parts.find((r) => r.key['internal_pn'] === pn)?.values['chip_type_id']).toBe(dcdc);
}
expect(parts.find((r) => r.key['internal_pn'] === 'AMS1117-3.3')?.values['chip_type_id']).toBe(ldo);
});
it('AMS1117-3.3 的参数行按 LDO 的字段清单播齐 —— 它要能被真拿来比', () => {
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
const names = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo).map((r) => r.values['param_name']);
const rows = seedRowsOf('parts', 'parameter').filter((r) => r.key['internal_pn'] === 'AMS1117-3.3');
expect(rows.map((r) => r.key['param_name']).sort()).toEqual([...names].sort());
});
});
- Step 3: 跑一遍,确认它失败
Run: pnpm exec vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts > /tmp/red1.txt 2>&1; echo "EXIT=$?"; cat /tmp/red1.txt
Expected: FAIL —— 两张表不存在。把 /tmp/red1.txt 整份贴进报告,不要裁剪。
- Step 4: 加两张表的物化
在 seed-mvp-data.ts 的物化清单里,parts.profile 之前加(字典要先于引用它的表):
{
address: { domain: 'parts', object: 'chip_type' },
kind: 'master',
label: '芯片类型',
fields: [
/*
* **整数自增主键**:人在件上「+新建类型」时这一格留空,由基座按「现有最大 + 1」续号
* (与 I01 的序号主键同一条路)。**空着的自增列不是身份线索,是「给我续个号」。**
*/
f('type_id', 'integer', '类型ID', true),
f('type_name', 'text', '类型名称'),
f('description', 'text', '类型说明'),
],
},
{
address: { domain: 'parts', object: 'type_param' },
kind: 'master',
label: '芯片类型核心参数字段',
fields: [
f('type_id', 'integer', '所属类型', true),
f('param_name', 'text', '核心参数名', true),
/* **AI 分析时读的就是这一列** —— 参数名本身说不清「它为什么要紧」 */
f('param_desc', 'text', '参数说明'),
/*
* 约束类型从 `parts.parameter`(每颗料各标一次)搬到这里(按类型标一次)——
* 「同一个参数在两颗料上标法不一致」这件事从此在结构上不可能发生。
*/
f('constraint_type', 'text', '约束类型'),
f('param_unit', 'text', '参考单位'),
],
},
并在 parts.profile 的字段清单里加一行:
f('chip_type_id', 'integer', '芯片类型'),
- Step 5: 加种子行
在 parts.parameter 的种子行之前加两个类型与十二条字段(type_id 用 1 / 2 两个字面值,与既有种子的写法一致):
{ address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 1 }, values: { type_id: 1, type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器:靠电感储能与高频开关把输入电压降到目标输出,效率高、发热小,外围需要电感与输出电容。' } },
{ address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 2 }, values: { type_id: 2, type_name: 'LDO 线性稳压器', description: '低压差线性稳压器:靠调整管压降稳压,外围简单、噪声低,压差与电流之积全部转成热耗。' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输入电压范围' }, values: { type_id: 1, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电压' }, values: { type_id: 1, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电流' }, values: { type_id: 1, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '开关频率' }, values: { type_id: 1, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '静态电流' }, values: { type_id: 1, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '工作温度' }, values: { type_id: 1, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '使能阈值' }, values: { type_id: 1, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输入电压范围' }, values: { type_id: 2, param_name: '输入电压范围', param_desc: '允许的输入直流电压区间。', constraint_type: 'hard', param_unit: 'V' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电压' }, values: { type_id: 2, param_name: '输出电压', param_desc: '固定版给定值;可调版需外围分压设定。', constraint_type: 'hard', param_unit: 'V' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电流' }, values: { type_id: 2, param_name: '输出电流', param_desc: '满载持续输出能力;线性稳压器受热耗限制,实际可用电流常低于标称。', constraint_type: 'hard', param_unit: 'A' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '压差' }, values: { type_id: 2, param_name: '压差', param_desc: '维持稳压所需的最小输入输出电压差。压差不够就稳不住,输出跟着输入掉。', constraint_type: 'hard', param_unit: 'V' } },
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '工作温度' }, values: { type_id: 2, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。', constraint_type: 'hard', param_unit: '℃' } },
给两颗既有料的 parts.profile 种子行各加 chip_type_id: 1;再播一颗 LDO 主记录与它的五行参数(这一颗是「类型不一致 → 硬拦」那条 e2e 用例的唯一素材,没有它那条路测不了):
{
address: { domain: 'parts', object: 'profile' },
key: { internal_pn: 'AMS1117-3.3' },
values: {
internal_pn: 'AMS1117-3.3', chip_type_id: 2, brand_name: 'AMS',
manufacturer_name: 'Advanced Monolithic Systems', specification_description: '3.3V 固定输出低压差线性稳压器,SOT-223',
lifecycle_status: 'active', datasheet_ref: 'https://example.invalid/ams1117', datasheet_version: 'Rev.C',
},
},
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围', param_value: '4.75~15', param_unit: 'V', param_note: '最低受压差限制' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电压' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电压', param_value: '3.3', param_unit: 'V', param_note: '固定版' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电流' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电流', param_value: '1', param_unit: 'A', param_note: '受散热限制' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '压差' }, values: { internal_pn: 'AMS1117-3.3', param_name: '压差', param_value: '1.3', param_unit: 'V', param_note: '满载时' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '工作温度' }, values: { internal_pn: 'AMS1117-3.3', param_name: '工作温度', param_value: '-40~+125', param_unit: '℃', param_note: '' } },
既有 parts.parameter 种子行里的 constraint_type 全部删掉 —— 它搬到字典上了,留着会让「同一个参数两处各有一个约束」重新可能。
- Step 6: 跑测试与全量门禁
Run:
pnpm exec vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts > /tmp/green1.txt 2>&1; echo "EXIT=$?"; cat /tmp/green1.txt
pnpm gates
Expected: 前者 5 条全 PASS(整份贴进报告);pnpm gates 退出码 0。
⚠️ 现在先不要跑
pnpm db:migrate/db:seed—— 那会动开发库,而开发库与稳定版共用。 落库统一放到 Task 12(装包与真库跑通)一次做,那时才有完整的活动包配套。
- Step 7: 提交
git add apps/api/src/app/seed-mvp-data.ts apps/api/src/app/__tests__/seed-mvp-data.test.ts
git commit -m "$(cat <<'EOF'
feat(seed): 芯片类型字典两张表 —— 约束类型从「每颗料各标一次」搬到「按类型标一次」
两颗料是不是用同一套参数名、同一套软硬约束,从此是结构保证而不是措辞叮嘱:
字段清单挂在类型上,一颗料关联一个类型。此前靠 parts.parameter.param_name 的
自指引用墙硬咬(措辞拦了三次没拦住才砌的),那堵墙可以退休了。
另播一颗 LDO(AMS1117-3.3)—— 它是「两颗料类型不一致 → 硬拦」那条路的唯一素材。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 2: 清单 —— 台账声明与契约
Files:
- Modify:
activities/part-substitution-assessment/src/manifest.ts(declarations.ledger与contract两段整体重写) - Modify:
activities/part-substitution-assessment/src/constants.ts - Test:
activities/part-substitution-assessment/__tests__/activity.test.ts
Interfaces:
- Consumes: Task 1 物化的
parts.chip_type/parts.type_param/parts.profile.chip_type_id - Produces: 七张台账的声明(两张字典 + P-08 主/参数 + 案卷两张 + P-11 主/明细 + P-06)、启动两参、单一出口
assessment-released、九条验收判据、预算
这一步做完包应当仍能 typecheck、包内自测绿。 案例模型还是旧的九步 —— 下一个任务才换, 所以这一步先不要动
case-model.ts;manifest里对旧步骤的引用(ASSESSMENT_STEPS等)原样留着。 若旧剧本引用了本步删掉的台账地址导致包内自测红,把那几条断言临时注掉并在报告里点名,Task 3 会补回。
- Step 1: 写失败的测试
把 __tests__/activity.test.ts 里「台账声明」那一组整体换成:
const ledgerOf = (address: string) =>
manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address);
const colOf = (address: string, name: string) => ledgerOf(address)?.fields.find((f) => f.name === name);
describe('台账声明', () => {
it('恰好九条声明(七个业务对象,P-08 与 P-11 各是主表 + 子表)', () => {
expect(manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`).sort()).toEqual([
'parts.chip_type', 'parts.parameter', 'parts.profile', 'parts.type_param',
'sub_eval.case', 'sub_eval.part',
'substitution.difference_item', 'substitution.relation', 'validation.record',
].sort());
});
it('类型字典:type_id 是自增主键 —— 人在件上新建类型时留空,由基座续号', () => {
expect(colOf('parts.chip_type', 'type_id')).toMatchObject({ primaryKey: true, type: 'integer', auto: true });
expect(colOf('parts.chip_type', 'type_name')?.required).toBe(true);
});
it('字段字典:复合主键 + 约束类型长在这里(不在每颗料上)', () => {
expect(ledgerOf('parts.type_param')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
expect(colOf('parts.type_param', 'type_id')?.refersTo).toBe('parts.chip_type.type_id');
expect(colOf('parts.type_param', 'param_desc')?.required).toBe(true);
expect(colOf('parts.type_param', 'constraint_type')).toMatchObject({ required: true, tally: ['hard', 'soft'] });
});
it('P-08:类型必填并引用字典;createOnly 撤掉 —— 在库老料要能补上类型这一格', () => {
expect(colOf('parts.profile', 'chip_type_id')).toMatchObject({ required: true, type: 'integer', refersTo: 'parts.chip_type.type_id' });
expect(ledgerOf('parts.profile')?.createOnly).toBeUndefined();
/* 封装与工艺本来就是「核心参数」—— 它们该以类型字段的身份出现在字典里,不在主记录上另开一列 */
for (const gone of ['package', 'process_technology', 'eol_pcn_status', 'application_scenarios']) {
expect(colOf('parts.profile', gone)).toBeUndefined();
}
});
it('参数子表:约束类型与自指引用墙都退休了', () => {
expect(colOf('parts.parameter', 'constraint_type')).toBeUndefined();
expect(colOf('parts.parameter', 'param_name')?.refersTo).toBeUndefined();
expect(colOf('parts.parameter', 'param_name')?.distinct).toBeUndefined();
expect(colOf('parts.parameter', 'param_value')?.required).toBe(true);
expect(ledgerOf('parts.parameter')?.createOnly).toBeUndefined();
});
it('案卷两张:part 一单两行(配方遍历的唯一来源)、case 一单一行', () => {
expect(ledgerOf('sub_eval.part')?.caseKey).toBe('eval_ref');
expect(ledgerOf('sub_eval.part')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref', 'internal_pn']);
/* 「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种 */
expect(colOf('sub_eval.part', 'chip_type_id')).toMatchObject({ required: true, distinct: true });
expect(colOf('sub_eval.part', 'exists_status')?.tally).toEqual(['found', 'absent']);
expect(ledgerOf('sub_eval.case')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref']);
});
it('P-11 明细带合成列 judgement_class —— 推荐等级要数的是「硬约束不符有几条」', () => {
expect(colOf('substitution.difference_item', 'judgement_class')).toMatchObject({
required: true,
tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'],
});
/* 「判断依据」不是文件引用 —— 不写 P-10 了,名字得说实话 */
expect(colOf('substitution.difference_item', 'evidence_note')?.required).toBe(true);
expect(colOf('substitution.difference_item', 'evidence_ref')).toBeUndefined();
expect(colOf('substitution.difference_item', 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' });
});
it('P-11 主记录:退休三列,等级只剩三档', () => {
for (const gone of ['substitution_type', 'confidence_score', 'analysis_report_ref']) {
expect(colOf('substitution.relation', gone)).toBeUndefined();
}
expect(colOf('substitution.relation', 'recommendation_level')?.tally).toEqual(['recommended', 'conditional', 'not_recommended']);
});
it('P-06:关联的是本单案号(P-01 不在本活动里),类型加了 parametric 一档', () => {
expect(colOf('validation.record', 'eval_ref')?.required).toBe(true);
expect(colOf('validation.record', 'request_id')).toBeUndefined();
expect(colOf('validation.record', 'validation_type')?.tally).toContain('parametric');
});
it('这个活动不碰 P-01 / P-10 / 客户档案', () => {
for (const gone of ['demand.request', 'parts.document', 'customer.profile']) {
expect(ledgerOf(gone)).toBeUndefined();
}
});
});
describe('契约', () => {
it('启动两参,都选填 —— 缺了由第一步派工单补', () => {
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
expect(Object.keys(props)).toEqual(['originalInternalPn', 'candidateInternalPn']);
expect(manifest.contract.input.data['required']).toEqual([]);
/* 运营端启动表单:两格都是「可选可新写」的下拉 */
for (const key of ['originalInternalPn', 'candidateInternalPn']) {
expect(props[key]?.['x-control']).toBe('combo');
expect(props[key]?.['x-options']).toMatchObject({ ledger: 'parts.profile', value: 'internal_pn' });
}
});
it('零工具 —— 这是这次重写的立场,不是疏漏', () => {
expect(manifest.contract.resources.tools).toEqual([]);
expect(manifest.declarations.tools).toEqual([]);
});
it('单一出口,载荷是已落地的事实', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['assessment-released']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('判据全读基座数 —— 任何一边都不许是活动自己写的汇总列', () => {
const machine = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L1' || c.level === 'L2');
expect(machine.length).toBeGreaterThanOrEqual(5);
for (const c of machine) {
expect(c.predicate).toBeDefined();
/* 基座读数的形状:域_对象_rows / _missing / _distinct_列 / tally 计数 */
expect(c.predicate).toMatch(/_(rows|missing|distinct_[a-z_]+|[a-z_]+)\b/);
}
});
it('预算:调人三次(补数据 / 审核 / 放行),模型只出场一次', () => {
expect(manifest.contract.resources.budget.humanCalls).toBe(8);
expect(manifest.contract.authorization.humanCallBudget).toBe(8);
expect(manifest.contract.resources.models).toEqual(['deepseek-v4-flash']);
});
});
- Step 2: 跑一遍,确认它失败
Run: pnpm exec vitest run activities/part-substitution-assessment > /tmp/red2.txt 2>&1; echo "EXIT=$?"; cat /tmp/red2.txt
Expected: FAIL(声明还是旧的七张主数据 + 三张单据)。整份贴进报告。
- Step 3: 重写
declarations.ledger
照 spec §3 逐张写。col() 帮手在文件里已有,沿用。九张(spec §3 说的「六张 + P-06」是按业务对象数,声明上是九条):
parts.chip_type · parts.type_param · parts.profile · parts.parameter · sub_eval.part · sub_eval.case · substitution.relation · substitution.difference_item · validation.record
逐张的列、档位、tally、refersTo、requiredWhen 以 spec §3.1–§3.4 为准(那几节把每一列都列全了),并把上面测试里断言到的每一条都满足。三处要写注释说清「为什么」:
/*
* **约束类型不在这里** —— 它长在 `parts.type_param`(按类型标一次)。
* 从前每颗料各标一次,同一个参数在两颗料上标法不一致就成了可能;
* 而比对是逐参数对着比的,两边约束档不同,判出来的四态就没有意义。
*/
/*
* **`distinct`**:「两颗料是不是同一个类型」从此是基座数得出来的数
* (`sub_eval_part_distinct_chip_type_id`)—— 不同取值恰好 1 种才算一致。
* 这一条是 S1 收步与 S2 入场的硬门:两边字段清单不是同一套,逐项比对无从谈起。
*/
/*
* **`judgement_class` 是合成列**:推荐等级要数的是「**硬约束**不符有几条」,
* 而基座读数只会数单列 —— 把「约束类型 × 四态」先合成一列,规则才算得出来。
* 由配方按 §3.5 的 when 链算,不是 AI 出的。
*/
-
Step 4: 重写
contract -
input.data:两个属性originalInternalPn/candidateInternalPn,required: [],各带x-control: 'combo'、x-options: { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }、x-order1/2、以及一句description(照 spec §4.1 的口径:库里有就选,没有就自己写,启动后由第一步派工单补)。datasheets与客户相关的三个键全部删掉。 -
output.mandatory:一口assessment-released,载荷照 spec §4.5(evalRef/relationId/originalPn/candidatePn/recommendationLevel/ 四态计数 /validationId)。 -
requiredDeliverables:两件 —— 差异评估结论(substitution.difference_item+substitution.relation)、落账内容(substitution.relation+validation.record)。 -
acceptanceCriteria:照 spec §7 的表写九条(五条机检 L1/L2、两条 L4 裁量、一条 L5 责任关)。谓词逐条用基座读数;level-by-rule那条把 §3.5 的规则写成 S-FEEL 的析取式。 -
prohibitions:保留「UNKNOWN 是一等公民」「硬约束 MISMATCH 不得被措辞改判」「不写客户批准状态」「只答技术可替代性」四条;删掉与 N 候选、外部系统相关的两条。 -
resources:tools: []、models: ['deepseek-v4-flash']、roles: ['FAE/RI']、budget: { steps: 40, tokens: 300_000, activeMinutes: 60, humanCalls: 8 };authorization.humanCallBudget: 8。
constants.ts 里 TOOL_COMPARE / TOOL_RECOMMEND 两个常量删掉(它们在工具注册表里的退役排在 Task 10)。
assets 这一段也要现在处理,否则 Task 2–Task 9 之间包是不自洽的(清单引着已经不存在的东西):
-
assets.businessModel.dmn:去掉 D01 / D02 两张判定表(四态改 AI 判、等级改规则算,§3.5 是唯一真源); -
assets.skills:置空;contract.resources.skills:置空(skills.ts的删除排在 Task 10); -
assets.viewTemplates:暂时只留activity-ledger一张(三张件由 Task 7–9 各自加回); -
assets.distillHooks:改写成一句与四步对得上的归纳提示,或整个去掉 —— 它是给模型的,而这个活动没有 agent。 -
Step 5: 升版本号
identity.packageVersion 从 1.7.3 升到 2.0.0 —— 这是重写,不是改。同版重装是 no-op(静默丢失,踩过)。
- Step 6: 跑测试与全量门禁
Run:
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green2.txt 2>&1; echo "EXIT=$?"; cat /tmp/green2.txt
pnpm gates
Expected: 新写的那两组全绿;pnpm gates 退出码 0。旧剧本引用已删台账导致的断言若被临时注掉,在报告里逐条点名。
- Step 7: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 清单重写 —— 九张台账、两个启动参数、零工具
台账:两张类型字典 + P-08 主/参数(撤 createOnly、封装工艺归字典)+ 案卷两张 + P-11 主/明细 + P-06。
契约:启动只剩原 PN 与候选 PN 两格且都选填(缺了由第一步派工单补);tools 清空 —— 这是立场不是疏漏。
判据全读基座数;明细加合成列 judgement_class,因为「硬约束不符有几条」不是单列 tally 数得出来的。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 3: 案例模型骨架 —— 四步、四道哨兵、零 guide
Files:
- Modify:
activities/part-substitution-assessment/src/case-model.ts(整体重写) - Test:
activities/part-substitution-assessment/__tests__/steps.test.ts(整体重写)
Interfaces:
- Consumes: Task 2 的九张台账声明
- Produces:
CMMN_XML(四个计划项 + 三道入场哨兵)·NARRATIVE(瘦身)·ASSESSMENT_STEPS(四条剧本,guide: '',auto先各放一笔say)·ASSESSMENT_PHASES(三格)
这一步之后包是完整可装的:四步俱在、判据挂上、
guide全空(靠 R8 那条 schema 放宽)。 配方的实体在 Task 4–6 逐步填进去;view先不写(它是可选的),Task 7–9 各自补上自己那一张。
- Step 1: 写失败的测试
__tests__/steps.test.ts 整体换成:
import { describe, expect, it } from 'vitest';
import { planStepSchema } from '@contracts';
import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
const stepOf = (item: string) => ASSESSMENT_STEPS.find((s) => s.item === item);
describe('四步案例模型', () => {
it('恰好四步,次序即业务次序', () => {
expect(ASSESSMENT_STEPS.map((s) => s.item)).toEqual(ITEMS);
});
it('每一步都过得了 PlanStep 契约', () => {
for (const step of ASSESSMENT_STEPS) expect(planStepSchema.safeParse(step).success).toBe(true);
});
it('**全程零 guide、零工具** —— 这是这次重写的立场', () => {
for (const step of ASSESSMENT_STEPS) {
expect(step.guide).toBe('');
expect(step.tools).toEqual([]);
expect(step.auto).toBeDefined();
}
});
it('CMMN 里四个计划项都在,且触达世界的两步标了人工激活', () => {
for (const item of ITEMS) expect(CMMN_XML).toContain(`id="${item}"`);
/* 审核与放行触达的是人 —— 完成与否不该由数据形状推出来 */
for (const item of ['S3_review', 'T_release']) {
expect(new RegExp(`id="${item}"[^>]*manualActivation="true"`).test(CMMN_XML)).toBe(true);
}
});
it('**触达世界的两步不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
expect(stepOf('S1_inputs')?.doneWhen).toBeTruthy();
expect(stepOf('S2_assess')?.doneWhen).toBeTruthy();
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
});
it('**判据不许编一个数不出来的数**:doneWhen 里不出现「本类型有几个字段」这类', () => {
/* `parts_type_param_rows` 数的是本单草稿写下的字段行(人新建的那几个),不是整本字典 —— 拿它当分母是错的 */
for (const step of ASSESSMENT_STEPS) {
expect(step.doneWhen ?? '').not.toContain('parts_type_param_rows');
}
});
it('S1 收步:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺', () => {
const done = stepOf('S1_inputs')?.doneWhen ?? '';
expect(done).toContain('sub_eval_part_exists_status_found = 2');
expect(done).toContain('sub_eval_part_missing = 0');
expect(done).toContain('sub_eval_part_distinct_chip_type_id = 1');
expect(done).toContain('parts_profile_missing = 0');
expect(done).toContain('parts_parameter_missing = 0');
});
it('S2 收步:明细写了且不缺、关系一行、验证记录一行', () => {
const done = stepOf('S2_assess')?.doneWhen ?? '';
expect(done).toContain('substitution_difference_item_rows > 0');
expect(done).toContain('substitution_difference_item_missing = 0');
expect(done).toContain('substitution_relation_rows = 1');
expect(done).toContain('validation_record_rows = 1');
});
it('两张卡各带人工验收清单 —— 一条一句人眼核得动的动作', () => {
for (const item of ['S3_review', 'T_release']) {
const list = stepOf(item)?.acceptanceChecklist ?? [];
expect(list.length).toBeGreaterThanOrEqual(4);
for (const line of list) expect(line.length).toBeGreaterThan(6);
}
expect(stepOf('S3_review')?.cardVariant).toBe('review');
});
it('阶段:除放行外每一项恰好属于一个阶段', () => {
const covered = ASSESSMENT_PHASES.flatMap((p) => p.items);
expect(covered.sort()).toEqual(['S1_inputs', 'S2_assess', 'S3_review'].sort());
expect(new Set(covered).size).toBe(covered.length);
});
it('说明书只讲整单的事 —— 逐步做法归配方,而配方不是给模型看的', () => {
expect(NARRATIVE.length).toBeLessThan(2000);
/* **示例不引具体料号与参数名** —— 模型会把示例当成这一单的值(2026-08-27 复现三次) */
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值']) {
expect(NARRATIVE).not.toContain(forbidden);
}
});
});
- Step 2: 跑一遍,确认它失败
Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts > /tmp/red3.txt 2>&1; echo "EXIT=$?"; cat /tmp/red3.txt
Expected: FAIL(现在是九步)。整份贴进报告。
- Step 3: 写 CMMN
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="part-substitution-assessment">
<!--
案卷项的 id 就是任务视图里的键名 —— **全是基座读数**(本单草稿写下的行数 / 缺必填数 /
按值计数 / 不同值个数)。这一版里**一面记账旗都没有**:旗是活动自己写的数,
而「机检只读基座数」这条纪律在本仓被违反过六次,每次都是同一种事故。
-->
<caseFileModel>
<caseFileItem id="sub_eval_part_exists_status_found" name="两颗料里已在库的个数(基座现数)"/>
<caseFileItem id="sub_eval_part_missing" name="本单两颗料缺必填的行数(基座现数)"/>
<caseFileItem id="sub_eval_part_distinct_chip_type_id" name="两颗料用到的不同类型个数(基座现数不同值)"/>
<caseFileItem id="parts_profile_missing" name="本单碰过的芯片主数据缺必填的行数(基座现数)"/>
<caseFileItem id="parts_parameter_missing" name="本单碰过的参数行缺必填的行数(基座现数)"/>
<caseFileItem id="substitution_difference_item_rows" name="差异明细行数(基座现数)"/>
<caseFileItem id="substitution_difference_item_missing" name="差异明细缺必填的行数(基座现数)"/>
<caseFileItem id="substitution_relation_rows" name="替代关系行数(基座现数)"/>
<caseFileItem id="substitution_relation_missing" name="替代关系缺必填的行数(基座现数)"/>
<caseFileItem id="validation_record_rows" name="验证记录行数(基座现数)"/>
<caseFileItem id="validation_record_missing" name="验证记录缺必填的行数(基座现数)"/>
</caseFileModel>
<casePlanModel id="plan" name="差异评估" autoComplete="true">
<!-- ① 入参齐备:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺 —— 分析那一步的入场前提 -->
<sentry id="s_inputs_ready">
<onPart id="op_i" caseFileItemRef="sub_eval_part_exists_status_found"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_i"><condition>sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and sub_eval_part_distinct_chip_type_id = 1 and parts_profile_missing = 0 and parts_parameter_missing = 0</condition></ifPart>
</sentry>
<!-- ② 结论已成形:逐条明细、一条关系、一条验证记录都写下了且不缺 —— 审核那一步的入场前提 -->
<sentry id="s_assessed">
<onPart id="op_a" caseFileItemRef="substitution_difference_item_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_a"><condition>substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and substitution_relation_rows = 1 and validation_record_rows = 1</condition></ifPart>
</sentry>
<!-- ③ 放行的入场前提与 ② 同源:审核那一步收没收由人答卡决定,不在这儿判 -->
<sentry id="s_reviewable">
<onPart id="op_r" caseFileItemRef="substitution_relation_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_r"><condition>substitution_relation_rows = 1 and substitution_relation_missing = 0 and validation_record_missing = 0</condition></ifPart>
</sentry>
<planItem id="S1_inputs" name="校验入参与补数据" definitionRef="d_inputs"/>
<planItem id="S2_assess" name="逐项差异分析" definitionRef="d_assess">
<entryCriterion id="e_assess" sentryRef="s_inputs_ready"/>
</planItem>
<!-- 审核与放行**触达的是人**:标人工激活,且不给 doneWhen —— 完成与否不该由数据形状推出来 -->
<planItem id="S3_review" name="差异评估结论审核" definitionRef="d_review" manualActivation="true">
<entryCriterion id="e_review" sentryRef="s_assessed"/>
</planItem>
<planItem id="T_release" name="落账放行" definitionRef="d_release" manualActivation="true">
<entryCriterion id="e_release" sentryRef="s_reviewable"/>
</planItem>
<humanTask id="d_inputs" name="校验入参与补数据"/>
<humanTask id="d_assess" name="逐项差异分析"/>
<humanTask id="d_review" name="差异评估结论审核"/>
<humanTask id="d_release" name="落账放行"/>
</casePlanModel>
</case>
</definitions>`;
manualActivation的位置照本仓cmmn-parse.ts认的那一处写。 动手前先grep -n "manualActivation" apps/api/src/modules/loop-engine/internal/domain/cmmn-parse.ts确认它是从planItem还是从humanTask上读的,用解析器真认的那一处;上面的用例断言的是 「id 与 manualActivation 出现在同一个标签里」,按解析器的口径摆好即可。
- Step 4: 写说明书(瘦身)
NARRATIVE 只讲整单的事,不讲逐步做法(逐步做法在配方里,而配方不是给模型看的)。2000 字以内,写清:这个活动答什么问题(候选料在技术上能不能替代原料)· 四步各自的产出 · 三条不许(不伪造参数值、硬约束不符不得被措辞改判、不写客户批准状态)。
示例一律用占位形(<料号> / <参数名>),不许出现真实料号与参数名 —— 关思考的模型会把示例当成这一单的值,本仓 2026-08-27 复现过三次。
- Step 5: 写四条剧本(配方先各放一笔
say)
export const ASSESSMENT_STEPS = [
{
item: 'S1_inputs',
intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补',
guide: '',
writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'],
reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'],
tools: [],
doneWhen:
'sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and ' +
'sub_eval_part_distinct_chip_type_id = 1 and parts_profile_missing = 0 and parts_parameter_missing = 0',
auto: { do: [{ kind: 'say', text: '正在核对两颗料的在库状态与参数齐备情况。' }] },
},
{
item: 'S2_assess',
intent: '把两颗料的核心参数逐条交给模型判四态,系统按规则算推荐等级、风险与验证要求',
guide: '',
writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'],
reads: ['parts.type_param', 'parts.parameter', 'sub_eval.part', 'sub_eval.case'],
tools: [],
doneWhen:
'substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and ' +
'substitution_relation_rows = 1 and validation_record_rows = 1',
auto: { do: [{ kind: 'say', text: '正在逐条比对两颗料的核心参数。' }] },
},
{
item: 'S3_review',
intent: '把逐条结论摆给人过目,他就地改四态与影响说明,改到没问题为止',
guide: '',
writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'],
reads: ['substitution.difference_item', 'substitution.relation'],
tools: [],
cardVariant: 'review',
/* 人工验收清单:一条一句他拿着这份工单要**亲自核**的动作,不是系统判的判据 */
acceptanceChecklist: [
'逐条对过原料与候选料的参数值,没有把需求边界写成候选料的问题',
'硬约束那几条逐条看过,没有一条是「没证据也判了 MATCH」',
'判 UNKNOWN 的都写了去向:实测哪一项、回填到哪儿',
'影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值',
],
auto: { do: [{ kind: 'say', text: '差异结论已备好,请过目。' }] },
},
{
item: 'T_release',
intent: '把这一签下去会落哪几张表、几行摆清楚,人签字后落账并发口',
guide: '',
writes: ['sub_eval.case'],
reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'sub_eval.case'],
tools: [],
acceptanceChecklist: [
'推荐等级与四态分布对得上:有硬约束不符的没给「推荐」',
'待验项清单认可:该实测的都列出来了,没有该验而漏掉的',
'落账内容核对过:进哪几张表、各几行,与屏上摆的一致',
'参数值来源可追:每一条都能说出它是库里既有的还是这一单补录的',
'签下去就发给下游,这一步之后本活动不再改动这些行',
],
auto: { do: [{ kind: 'say', text: '拟落账内容已备好,请核对后签字。' }] },
},
] as const;
export const ASSESSMENT_PHASES = [
{ name: '校验入参', items: ['S1_inputs'] },
{ name: '差异分析', items: ['S2_assess'] },
{ name: '结论审核', items: ['S3_review'] },
] as const;
阶段里没有
T_release—— 放行那一格由基座统一加(与 E04 / I01 同款)。 用例钉的正是「除放行外每一项恰好属于一个阶段」:漏一格,人手上办的那件事在进度条上就没有位置。
- Step 6: 跑测试与全量门禁
Run:
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green3.txt 2>&1; echo "EXIT=$?"; cat /tmp/green3.txt
pnpm gates
Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。
- Step 7: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 案例模型换成四步 —— 零 guide、零工具、零记账旗
九步砍成四步:校验入参 / 差异分析 / 结论审核 / 落账放行。
三道入场哨兵全读基座数,caseFileModel 里一面记账旗都没有 ——
旗是活动自己写的数,而「机检只读基座数」这条在本仓被违反过六次。
审核与放行触达的是人:标人工激活、不给 doneWhen。
说明书瘦成「整单的事」,示例一律占位形 —— 模型会把示例当成这一单的值。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 4: S1 配方 —— 校验入参与补数据
Files:
- Modify:
activities/part-substitution-assessment/src/case-model.ts(S1_inputs.auto) - Modify:
activities/part-substitution-assessment/src/manifest.ts(sub_eval.part加一列found_pn_ref) - Test:
activities/part-substitution-assessment/__tests__/steps.test.ts
Interfaces:
- Consumes: Task 3 的剧本骨架;
AutoRecipe契约(五动词 / 八式表达式 / 四种地址) - Produces: S1 的六笔配方;
sub_eval.part.found_pn_ref(在库核对依据,refersTo parts.profile.internal_pn)
动手前先读《活动开发规范》§4.6 —— 它是配方怎么写的权威,含四条纪律。
- Step 1: 确认
sub_eval.part上有found_pn_ref(Task 2 照 spec §3.3 应当已经写了)
Run: grep -n "found_pn_ref" activities/part-substitution-assessment/src/manifest.ts
有就跳过这一步。 没有(Task 2 漏了)就在 role 之后补上 —— exists_status 不能凭空判,
判「在库」就必须拿得出一个基座查得到的料号:
/*
* **在库核对依据**:判 `found` 就得把在主数据里真找到的那个料号填在这里,
* 基座拿它去 `parts.profile` 查(`refersTo`),查无此值当场拒。
* 这一列的存在是为了让「在不在库」成为一次 `lookup` 的结果,而不是一句断言 ——
* 2026-08-27 实测过:原 PN 根本不在库,而模型把两颗都写成 found、交代里还说「都在库」。
*/
col({
name: 'found_pn_ref',
type: 'text',
label: '在库核对依据',
refersTo: 'parts.profile.internal_pn',
requiredWhen: { column: 'exists_status', value: 'found' },
}),
并确认 Task 2 那条「案卷两张」用例里有这一行断言(没有就补):
expect(colOf('sub_eval.part', 'found_pn_ref')?.refersTo).toBe('parts.profile.internal_pn');
- Step 2: 写失败的测试
追加到 steps.test.ts:
describe('S1 配方', () => {
const recipe = stepOf('S1_inputs')?.auto;
const acts = recipe?.do ?? [];
const kinds = acts.map((a) => a.kind);
it('六笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => {
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']);
/* 案卷排在骨架两笔之后 —— gate_reason 引的 _missing 要等骨架铺出来才有值,提前算它那句话是空的 */
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
]);
});
it('案号由 let 铸,只从本单开案时刻取材', () => {
expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
});
it('两颗料由字面两行立起来 —— 这张表是后面每一笔遍历的唯一来源', () => {
const part = acts[0];
expect(part?.kind === 'project' && 'rows' in part.from && part.from.rows).toHaveLength(2);
/* 在库与否是一次 lookup 的结果,不是一句断言:先取依据,再按依据判 */
const fields = part?.kind === 'project' ? part.fields : {};
expect(fields['found_pn_ref']).toMatchObject({ lookup: 'parts.profile', take: 'internal_pn' });
expect(fields['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' });
});
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[2];
expect(params?.kind === 'project' && params.prune).toBe(true);
const from = params?.kind === 'project' ? params.from : undefined;
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } });
const fields = params?.kind === 'project' ? params.fields : {};
expect(fields['param_value']).toMatchObject({ lookup: 'parts.parameter', take: 'param_value' });
});
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
const card = acts[4];
const when = card?.kind === 'card' ? (card.when ?? '') : '';
/*
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
* 于是未绑定 —— 拿它当条件,恰恰在一切正常时把卡发出去。条件只用恒有值的基座读数。
*/
expect(when).not.toContain('gate_reason');
expect(when).toContain('sub_eval_part_exists_status_found < 2');
expect(when).toContain('parts_parameter_missing > 0');
});
it('卡:数据工单、四张表都在闸上、且有 basis(基座拒发 basis 为空的工单)', () => {
const card = acts[4];
expect(card?.kind === 'card' && card.as).toBe('execution');
expect(card?.kind === 'card' && card.variant).toBe('data');
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']);
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
});
it('发卡条件与 doneWhen 是同一件事的正反面', () => {
const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
for (const reading of [
'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
]) {
expect(when).toContain(reading);
expect(stepOf('S1_inputs')?.doneWhen).toContain(reading);
}
});
});
- Step 3: 跑一遍,确认它失败
Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S1 配方" > /tmp/red4.txt 2>&1; echo "EXIT=$?"; cat /tmp/red4.txt
Expected: FAIL(现在只有一笔 say)。整份贴进报告。
- Step 4: 写配方
把 S1_inputs.auto 换成:
auto: {
/** 整单只有这一个案号,从本单开案时刻取材 —— **不许从正式表里别单的号里挑**(踩过) */
let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] } },
do: [
/*
* ① 本单两颗料 —— **后面每一笔遍历的唯一来源**。
* 挤进案卷一行里就没法 `where` 过滤,而不能过滤就会给已经在库的那颗也铺一遍空参数行。
*/
{
kind: 'project',
into: 'sub_eval.part',
from: {
rows: [
{ pn: { in: 'originalInternalPn' }, role: 'original' },
{ pn: { in: 'candidateInternalPn' }, role: 'candidate' },
],
},
fields: {
eval_ref: { in: 'evalRef' },
internal_pn: { col: 'pn' },
role: { col: 'role' },
/* 先取依据,再按依据判 —— 字段按声明顺序求值,算出来的进同一行的 S-FEEL 作用域 */
found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' },
exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' },
chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' },
},
},
/*
* ② 主数据骨架:**两颗料都铺、值回填现值**。
* 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。
* **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。
* 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。
*/
{
kind: 'project',
into: 'parts.profile',
prune: true,
from: { each: 'mine:sub_eval.part' },
fields: {
internal_pn: { col: 'internal_pn' },
chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' },
brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' },
lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' },
specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' },
manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' },
datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' },
datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' },
},
},
/*
* ③ 参数骨架 —— 三件事一笔办完:
* · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**;
* 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**;
* · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报;
* · **「在库但当年参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。
*/
{
kind: 'project',
into: 'parts.parameter',
prune: true,
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
},
fields: {
internal_pn: { outer: 'internal_pn' },
param_name: { col: 'param_name' },
param_unit: { col: 'param_unit' },
param_value: {
lookup: 'parts.parameter',
by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } },
take: 'param_value',
else: '',
},
param_note: {
lookup: 'parts.parameter',
by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } },
take: 'param_note',
else: '',
},
},
},
/*
* ④ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤)。
* **这一笔必须排在②③之后,不许提前**(2026-08-29 走查实测,评审用活探针复现):
* 它引的 `parts_profile_missing` / `parts_parameter_missing` 要等②③把骨架行铺出来才有值。
* 排在前面时,引擎在②③写入**之前**给它读到 0 —— 卡照样正确发出(⑤ 的 `when` 排在更后面,
* 读到的是刷新后的真缺口),但卡上那句「还差什么」是**空白的**,恰在人最想要一句解释的时候。
* **一笔读的是另一笔写出来的数,它就得排在那一笔后面。**
*/
{
kind: 'project',
into: 'sub_eval.case',
from: { once: true },
fields: {
eval_ref: { in: 'evalRef' },
chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' },
gate_reason: {
when: 'sub_eval_part_rows < 2',
then: '原料号与候选料号还没填全 —— 在下面把两格补上。',
else: {
when: 'sub_eval_part_exists_status_absent > 0',
then: '有料号库里查不到 —— 在下面把它的主数据建起来。',
else: {
when: 'sub_eval_part_missing > 0',
then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。',
else: {
when: 'sub_eval_part_distinct_chip_type_id > 1',
then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。',
else: {
when: 'parts_profile_missing + parts_parameter_missing > 0',
then: '还有必填格空着 —— 红带点名的那几格填上就能提交。',
else: '',
},
},
},
},
},
},
},
/*
* ⑤ 缺什么就派一张数据工单。
* **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定,
* 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。
*/
{
kind: 'card',
when:
'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or ' +
'sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0',
as: 'execution',
variant: 'data',
title: '数据工单 · 差异评估的输入还差几格',
basis: [
{ k: '原料号', v: { in: 'originalInternalPn' } },
{ k: '候选料号', v: { in: 'candidateInternalPn' } },
{ k: '还差什么', v: { in: 'gate_reason' } },
],
guide: [
'两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。',
'类型下拉里没有合适的,就新建一个,并把它的**核心参数字段**一并定下来:每个字段要有名字、一句说明、以及硬约束还是软约束。',
'说明写「这个参数为什么要紧」——**它是后面判定差异时唯一的解释来源**,写成参数名的复述等于没写。',
'硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)。',
'参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。',
],
standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。',
gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'],
},
{ kind: 'say', text: '已核对两颗料的在库状态与参数齐备情况。' },
],
},
- Step 5: 跑测试与全量门禁
Run:
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green4.txt 2>&1; echo "EXIT=$?"; cat /tmp/green4.txt
pnpm gates
Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。
- Step 6: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): S1 配方 —— 校验入参、铺格、缺什么派一张数据工单
在库与否是一次 lookup 的结果,不是一句断言:先取 found_pn_ref(refersTo 查无此值当场拒)再按它判。
参数骨架两颗料都铺、值回填现值 —— 于是「在库但当年参数没录全」也自动被红带拦住。
发卡的条件只用基座读数:gate_reason 为空串时那一列不写、未绑定,而 S-FEEL 里未绑定的名字
`!= ""` 求值为 true —— 拿它当条件恰恰会在一切正常时把卡发出去。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 5: S2 配方 —— AI 唯一一次出场,以及那套规则
Files:
- Modify:
activities/part-substitution-assessment/src/case-model.ts(S2_assess.auto) - Test:
activities/part-substitution-assessment/__tests__/steps.test.ts
Interfaces:
- Consumes: S1 落下的
sub_eval.part/sub_eval.case/parts.parameter;字典parts.type_param - Produces: S2 的五笔配方 ——
analyze(整个活动唯一一次模型调用)+ 三笔project+ 一笔say
这一步是整套设计的要害。 三处刻意的克制,评审会逐条核: ① 行由字典决定,不由 AI 决定 —— 明细按
parts.type_parameach出来,AI 的出参只是拿param_name去lookup的一张查表;它漏一条,那一格就是空,required立刻变成_missing > 0,闸与放行门当场拦住。 写成from each(AI 出参)的话,它少给两条就少两行,而每一行看着都合规。 ② 值是多少一个字都不重述 —— 基座读得到的事实不让模型转述(本仓踩过:模型念出的「输入上限 32V」 是示例里的数,不是这一单的)。 ③ 推荐等级、风险、摘要、推荐理由全由规则与模板拼 —— 于是永远不会出现「有硬约束不符却写着推荐」。
- Step 1: 写失败的测试
追加到 steps.test.ts:
describe('S2 配方 —— AI 只出场一次', () => {
const recipe = stepOf('S2_assess')?.auto;
const acts = recipe?.do ?? [];
it('五笔:分析 → 明细 → 关系 → 验证记录 → 交代;analyze 恰好一笔', () => {
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'say']);
expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
});
it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
const items = acts[1];
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
const fields = items?.kind === 'project' ? items.fields : {};
/* AI 的出参只是一张查表:漏一条,那一格就是空 —— required 立刻把它变成 _missing > 0 */
expect(fields['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' });
expect(fields['impact_statement']).toMatchObject({ lookup: 'result:items' });
expect(fields['evidence_note']).toMatchObject({ lookup: 'result:items' });
});
it('**值不取 AI 出参,一律 lookup 台账**', () => {
const fields = acts[1]?.kind === 'project' ? acts[1].fields : {};
for (const col of ['original_value', 'candidate_value']) {
expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' });
}
/* 约束类型来自字典这一行本身,不是 AI 说的 */
expect(fields['constraint_type']).toEqual({ col: 'constraint_type' });
});
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
const fields = acts[1]?.kind === 'project' ? acts[1].fields : {};
const jc = JSON.stringify(fields['judgement_class']);
expect(jc).toContain('hard_mismatch');
expect(jc).toContain('soft_mismatch');
/* 顺序要紧:先认四态,最后才按约束类型分硬软 */
expect(jc.indexOf('"match"')).toBeLessThan(jc.indexOf('hard_mismatch'));
});
it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => {
const rel = acts[2]?.kind === 'project' ? acts[2].fields : {};
const level = JSON.stringify(rel['recommendation_level']);
expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
expect(level).toContain('not_recommended');
/* 硬约束不符必须直接落到「不推荐」,排在最前 */
expect(level.indexOf('hard_mismatch')).toBeLessThan(level.indexOf('unknown'));
});
it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => {
const val = acts[3];
expect(val?.kind === 'project' && val.into).toBe('validation.record');
expect(val?.kind === 'project' && val.from).toEqual({ once: true });
const fields = val?.kind === 'project' ? val.fields : {};
expect(fields['validation_status']).toBe('not_started');
expect(fields['validation_type']).toBe('parametric');
});
it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => {
const analyze = acts[0];
const prompt = analyze?.kind === 'analyze' ? analyze.instruction : '';
expect(prompt).toContain('不要复述');
expect(prompt).toContain('unknown');
expect(prompt).toContain('硬约束');
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值', '输入电压范围']) {
expect(prompt).not.toContain(forbidden);
}
/* 出参 schema 顶层是对象,数组字段名与 into 对得上 */
const out = analyze?.kind === 'analyze' ? analyze.output : {};
expect(out['type']).toBe('object');
expect(analyze?.kind === 'analyze' && analyze.into).toBe('items');
});
it('AI 的入参**每一条都带说明** —— 参数名本身说不清「它为什么要紧」', () => {
const analyze = acts[0];
const fields = analyze?.kind === 'analyze' ? analyze.input.fields : {};
expect(fields['param_desc']).toEqual({ col: 'param_desc' });
expect(fields['constraint_type']).toEqual({ col: 'constraint_type' });
for (const col of ['original_value', 'candidate_value']) {
expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' });
}
});
});
- Step 2: 跑一遍,确认它失败
Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S2 配方" > /tmp/red5.txt 2>&1; echo "EXIT=$?"; cat /tmp/red5.txt
Expected: FAIL。整份贴进报告。
- Step 3: 写配方
把 S2_assess.auto 换成下面这份。let 里为什么不用 {in:'chip_type_id'}:chip_type_id 这个名字在
parts.profile / sub_eval.part / sub_eval.case 三张表上都有,而基座把「各对象最新一行」的字段名铺到顶层、
同名以后写的赢 —— 拿顶层那个名字取值等于赌哪张表最后写。改成对准一张表 lookup,不歧义。
auto: {
let: {
evalRef: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
typeId: { lookup: 'mine:sub_eval.case', by: {}, take: 'chip_type_id', else: '' },
originalPn: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' },
candidatePn: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' },
relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] },
},
do: [
/*
* ① **整个活动唯一一次模型调用**。系统把两颗料在该类型下的每一条核心参数
* 组装好交给它,它只答「这一条算什么、为什么」——**不给工具、不给记忆、不多轮**。
*/
{
kind: 'analyze',
model: 'deepseek-v4-flash',
into: 'items',
instruction: [
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
'',
'对**每一条**给出四态之一:',
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
'',
'三条硬规矩:',
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
'',
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
'',
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
].join('\n'),
input: {
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
param_name: { col: 'param_name' },
/* **AI 分析时读的就是它** —— 参数名本身说不清「它为什么要紧」 */
param_desc: { col: 'param_desc' },
constraint_type: { col: 'constraint_type' },
unit: { col: 'param_unit' },
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
original_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
},
},
output: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
properties: {
param_name: { type: 'string' },
difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
impact_statement: { type: 'string' },
evidence_note: { type: 'string' },
unknown_disposition: { type: 'string' },
},
},
},
},
},
},
/*
* ② 逐条明细 —— **行由字典 `each` 出来**,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表。
* 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
*/
{
kind: 'project',
into: 'substitution.difference_item',
prune: true,
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
substitution_relation_id: { in: 'relationId' },
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
parameter_name: { col: 'param_name' },
parameter_desc: { col: 'param_desc' },
parameter_unit: { col: 'param_unit' },
/* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
constraint_type: { col: 'constraint_type' },
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 */
judgement_class: {
when: 'difference_status = "match"', then: 'match',
else: {
when: 'difference_status = "unknown"', then: 'unknown',
else: {
when: 'difference_status = "conditional"', then: 'conditional',
else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' },
},
},
},
risk_level: {
when: 'judgement_class = "hard_mismatch"', then: 'high',
else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' },
},
rule_version: RULE_VERSION,
},
},
/*
* ③ 关系一行 —— **等级、风险、摘要、理由全由规则与模板拼,不让 AI 编**。
* 于是永远不会出现「有硬约束不符却写着推荐」;人在审核件上改了某一条四态,这一笔重跑,等级跟着变。
*/
{
kind: 'project',
into: 'substitution.relation',
from: { once: true },
fields: {
substitution_relation_id: { in: 'relationId' },
eval_ref: { in: 'evalRef' },
original_internal_pn: { in: 'originalPn' },
candidate_internal_pn: { in: 'candidatePn' },
chip_type_id: { in: 'typeId' },
recommendation_level: {
when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended',
else: {
when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional',
else: {
when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional',
else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' },
},
},
},
risk_level: {
when: 'recommendation_level = "not_recommended"', then: 'high',
else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' },
},
difference_summary: {
cat: [
'共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:',
{ in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ',
{ in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ',
{ in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ',
{ in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ',
{ in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。',
],
},
recommendation_reason: {
when: 'recommendation_level = "not_recommended"',
then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。',
else: {
when: 'recommendation_level = "recommended"',
then: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。',
},
},
verification_requirement: {
when: 'recommendation_level = "conditional"',
/* `{joinRows}` 未实现 —— 不逐个列参数名,指向明细表(那里逐条摆着,且人能就地改) */
then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。',
else: '',
},
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
analysis_status: 'fae_confirmed',
rule_version: RULE_VERSION,
},
},
/*
* ④ 验证记录一行 —— **一单固定一条**(用户定)。本活动只写「还得去验什么」,
* 状态一律 `not_started`:它不是实测结果,**不许虚构实测**。
*/
{
kind: 'project',
into: 'validation.record',
from: { once: true },
fields: {
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
eval_ref: { in: 'evalRef' },
validation_internal_pn: { in: 'candidatePn' },
validation_type: 'parametric',
validation_status: 'not_started',
test_condition: {
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。',
else: '无',
},
next_validation_action: {
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。',
else: '本轮无待验项。',
},
},
},
{ kind: 'say', text: '逐条差异结论已备好,推荐等级由规则按四态算出。' },
],
},
RULE_VERSION 从 constants.ts 引(它已存在;若被 Task 2 删掉了就补回来)。
- Step 4: 跑测试与全量门禁
Run:
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green5.txt 2>&1; echo "EXIT=$?"; cat /tmp/green5.txt
pnpm gates
Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。
- Step 5: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): S2 配方 —— AI 唯一一次出场,行由字典决定不由它决定
明细按类型字典 each 出来,AI 的出参只是拿参数名去 lookup 的一张查表:
它漏一条,那一行的四态就是空,required 立刻变成 _missing > 0,闸与放行门当场拦住。
从前是 from each(AI 出参) —— 少给两条就少两行,而每一行看着都合规。
值一律 lookup 台账,一个字不让它重述;推荐等级、风险、摘要、理由全由规则与模板拼。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 6: S3 与 S4 配方 —— 审核卡与落账放行
Files:
- Modify:
activities/part-substitution-assessment/src/case-model.ts(抽出两份共享投影;S3_review.auto/T_release.auto) - Test:
activities/part-substitution-assessment/__tests__/steps.test.ts
Interfaces:
- Consumes: Task 5 的关系与验证记录投影
- Produces:
RELATION_PROJECTION/VALIDATION_PROJECTION两个模块级常量(S2 与 S3 共用);S3 的四笔;S4 的两笔
为什么 S3 要重跑那两笔投影:人在审核件上改了某一条四态,推荐等级、风险、摘要、验证要求都得跟着变。 「让人直接改摘要」等于让他给自己没做的判断盖章 —— 所以摘要只读,改的是四态,系统重算。 投影必须是同一份,各写一份早晚分叉:抽成模块级常量,两处引用。
次序:投影在前、卡在后。 人还在填(quiet)时卡被跳过、投影照跑;人交回后(carded)卡这一笔 只跳过自己、投影仍跑 —— 「人的答案已经在草稿里」的那一刻,重算才发生。
- Step 1: 写失败的测试
追加到 steps.test.ts:
describe('S3 / S4 配方', () => {
const s3 = stepOf('S3_review')?.auto?.do ?? [];
const s4 = stepOf('T_release')?.auto?.do ?? [];
const s2 = stepOf('S2_assess')?.auto?.do ?? [];
it('S3 四笔:两笔重算在前、卡在后 —— 人答完那一刻重算才发生', () => {
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'card', 'say']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.relation');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('validation.record');
});
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
expect(s3[0]).toBe(s2[2]);
expect(s3[1]).toBe(s2[3]);
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[2];
expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
expect((card?.kind === 'card' ? card.options ?? [] : [])[0]?.value).toBe('approve');
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
});
it('S4 两笔:写结论一句话 → 提交放行', () => {
expect(s4.map((a) => a.kind)).toEqual(['project', 'submit']);
expect(s4[0]?.kind === 'project' && s4[0].into).toBe('sub_eval.case');
expect(s4[1]?.kind === 'submit' && typeof s4[1].rationale !== 'undefined').toBe(true);
});
it('`submit` 所在的步声明了人工验收清单 —— 系统提交不比模型提交宽松', () => {
expect((stepOf('T_release')?.acceptanceChecklist ?? []).length).toBeGreaterThanOrEqual(4);
});
it('两步都不给 doneWhen —— 收步由人答卡决定', () => {
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
});
});
- Step 2: 跑一遍,确认它失败
Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S3 / S4 配方" > /tmp/red6.txt 2>&1; echo "EXIT=$?"; cat /tmp/red6.txt
Expected: FAIL。整份贴进报告。
- Step 3: 把两笔投影抽成模块级常量
在 ASSESSMENT_STEPS 之前加(内容原样取自 Task 5 写的 S2 第③④笔),然后 S2 与 S3 都引用它们:
这是一次移动,不是重写:上一个任务已经把这两个对象字面量写在 case-model.ts 里了。
打开 case-model.ts,找到 S2_assess.auto.do 数组里的第 3 笔(into: 'substitution.relation' 那个对象字面量)
与第 4 笔(into: 'validation.record' 那个),整体剪切到 ASSESSMENT_STEPS 之前,各起一个名字:
/*
* **关系与验证记录的投影,S2 与 S3 共用同一份**。
* S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 ——
* 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。
*/
const RELATION_PROJECTION = { /* ← 从 S2 第 3 笔整体剪切过来,一个字不改 */ } as const;
const VALIDATION_PROJECTION = { /* ← 从 S2 第 4 笔整体剪切过来,一个字不改 */ } as const;
再把 S2 原来那两个位置换成对常量的引用。剪切前后 pnpm exec vitest run activities/part-substitution-assessment
必须仍然全绿 —— Task 5 写的那几条用例断言的就是这两笔的内容,它们此刻正是这次移动的安全网。
用例里 expect(s3[0]).toBe(s2[2]) 用的是 toBe(同一个对象引用),照抄一份不算数。
- Step 4: 写 S3 与 S4 的配方
/* S3_review.auto */
auto: {
do: [
/* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */
RELATION_PROJECTION,
VALIDATION_PROJECTION,
{
kind: 'card',
as: 'decide',
variant: 'review',
title: '差异评估结论审核',
ask: '逐条差异结论已备好。请在工作区的件上逐条核对;要改的就地改(四态、影响说明、判断依据都可改),改完回到这里确认。',
options: [{ value: 'approve', label: '确认通过', detail: '逐条核对无误,可以进入落账放行' }],
basis: [
{ k: '原料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' } },
{ k: '候选料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' } },
{ k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
],
},
{ kind: 'say', text: '差异结论已送审,请在件上逐条核对。' },
],
},
/* T_release.auto */
auto: {
do: [
{
kind: 'project',
into: 'sub_eval.case',
from: { once: true },
fields: {
eval_ref: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
summary: {
cat: [
{ lookup: 'mine:substitution.relation', by: {}, take: 'candidate_internal_pn', else: '' },
' 替代 ',
{ lookup: 'mine:substitution.relation', by: {}, take: 'original_internal_pn', else: '' },
':',
{ lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' },
],
},
},
},
{
kind: 'submit',
rationale: {
cat: [
'逐项差异评估已经审核通过:',
{ in: 'substitution_difference_item_rows' }, ' 条明细、1 条替代关系、1 条验证记录待落账。',
],
},
},
],
},
- Step 5: 跑测试与全量门禁
Run:
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green6.txt 2>&1; echo "EXIT=$?"; cat /tmp/green6.txt
pnpm gates
Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。
- Step 6: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): S3 审核卡与 S4 落账放行 —— 摘要只读,改的是四态
关系与验证记录的投影抽成模块级常量,S2 与 S3 共用同一份:
人改了某一条四态,等级 / 风险 / 摘要 / 验证要求跟着重算。
各写一份早晚分叉,而分叉那一刻屏上会同时摆着两个互相矛盾的等级。
次序是投影在前、卡在后:人交回后卡这一笔只跳过自己,投影仍跑 —— 重算才发生在该发生的那一刻。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 7: 件① input-supplement —— 评估输入补全(数据工单)
Files:
- Modify:
activities/part-substitution-assessment/src/views/report.ts(新增INPUT_SUPPLEMENT,删旧的五张) - Modify:
activities/part-substitution-assessment/src/views/target-style.ts(留P08_CSS,删无主的) - Modify:
activities/part-substitution-assessment/src/manifest.ts(viewTemplates加它)+case-model.ts(S1_inputs.view) - Test:
activities/part-substitution-assessment/__tests__/activity.test.ts
Interfaces:
- Consumes: Task 4 的 S1 配方(它铺的行就是这张件上要填的行)
- Produces: 视图模版
input-supplement
这张件三块竖排(spec §6.1),绑定契约如下 —— 模版只取值、不判断:
| 块 | 绑定 | 说明 |
|---|---|---|
| ① 要评估什么 | <tbody data-rows="mine:sub_eval.part" data-edit="parts.profile"> |
两行(行由系统铺,人不加行)。每行摆 internal_pn(主键,必挂 data-col —— 它不是编辑面,是行的钥匙)、在库状态徽(data-class-of="exists_status")、以及可改的主数据格 |
| ② 芯片类型 | <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"> |
选项从台账现读;下面 <tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"> 供新建类型时加字段行 |
| ③ 参数值 | <tbody data-rows="mine:parts.parameter" data-edit="parts.parameter"> |
一字段一行、两栏都可填;param_name 挂 data-col 当钥匙,param_value / param_note 可改 |
| 顶部红带 | <div data-class-of="gate_reason"> + <span data-slot="gate_reason"> |
「还差什么」由 S1 的配方算好摆上来;一格不缺时那条带子由样式表隐掉(v- 空值那一档 display:none),模版不判断 |
- Step 1: 写失败的测试
追加到 activity.test.ts:
const viewOf = (name: string) => manifest.assets.viewTemplates.find((v) => v.name === name)?.html ?? '';
describe('件① 评估输入补全', () => {
const html = viewOf('input-supplement');
it('在 viewTemplates 里,且 S1 指向它', () => {
expect(html).not.toBe('');
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement');
});
it('三块都在,行都绑本单草稿(不是整表)', () => {
expect(html).toContain('data-rows="mine:sub_eval.part"');
expect(html).toContain('data-rows="mine:parts.parameter"');
expect(html).toContain('data-rows="mine:parts.type_param"');
});
it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」', () => {
expect(html).toContain('data-options="parts.chip_type"');
expect(html).toContain('data-option-value="type_id"');
expect(html).toContain('data-option-label="type_name"');
/* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */
expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false);
});
it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => {
expect(html).toContain('data-col="internal_pn"');
expect(html).toContain('data-col="param_name"');
});
it('可改的三张表各有一道编辑闸', () => {
for (const address of ['parts.profile', 'parts.type_param', 'parts.parameter']) {
expect(html).toContain(`data-edit="${address}"`);
}
});
it('模版不算不判:没有内联脚本,红带的显隐交给样式表', () => {
expect(html).not.toContain('<script');
expect(html).toContain('data-class-of="gate_reason"');
});
it('表宽超出要能横着滑 —— 不许写成 overflow:hidden(E04/I01 抄错过一次)', () => {
expect(html).not.toMatch(/\.jc-tbl-wrap\s*\{[^}]*overflow\s*:\s*hidden/);
});
});
- Step 2: 跑一遍,确认它失败
Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件①" > /tmp/red7.txt 2>&1; echo "EXIT=$?"; cat /tmp/red7.txt
Expected: FAIL。整份贴进报告。
- Step 3: 写模版
用 report.ts 里既有的 doc({ css, alias, badge, hic?, tag, kicker, title, step, body, footTag }) 帮手(签名照文件里的),
css 用 P08_CSS(数据工单那一套版面语言,已是从目标件抽出来的)。body 按上面的绑定契约三块竖排。
动手前先读《HTML报告生成规范》 —— 尤其 §4 绑定全表、§6 编辑态(闸 / 孪生件 / 增删行 / 红带)、§10 陷阱表。 三条最容易踩的:
- 行容器的标签名不得在行模版内部再出现(用
<article>包<section>),否则行会被截成套娃; - 枚举列上下两态要说同一个字 —— 只读那一份别
data-slot直显机器码,用data-class-of徽标或data-by查表; - 空格子的胶囊用
data-blank+ CSS:empty::after,别在模版里判。
删掉 P08_MASTER_CREATE / P10_DOCUMENT_CREATE / EVAL_INPUT_LIST / DIFFERENCE_REVIEW / LANDING_CONFIRM 五个旧导出(Task 8、9 会新建同名的另两张),target-style.ts 里没有消费方的 CSS 常量一并删。
- Step 4: 跑测试与全量门禁
Run:
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green7.txt 2>&1; echo "EXIT=$?"; cat /tmp/green7.txt
pnpm gates
Expected: 全绿(整份贴进报告);退出码 0。
- Step 5: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 件① 评估输入补全 —— 类型下拉从台账现读
芯片类型是人在件上现造的,选项写死等于「只能选出包那天就有的那几种」。
行由系统铺、人不加行:要建哪颗料是启动参数就定死的事,他只填格子。
主键列挂 data-col 当行的钥匙 —— 漏了会把人改的行错配到别行上(2026-08-02 踩过)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 7b: 件① 补上「新建芯片类型」—— 空表加行这条路要先通
这一条是 2026-08-29 从 Task 7 的顾虑里立出来的(控制端裁定)。Task 7 交付的件让人从下拉里挑 一个已有类型(
data-options现读),这条主路是通的;但 spec 明写的另一半 —— 人在这张件上新建一个类型 并定下它的核心参数字段 —— 没做。它不是可选的:
- spec
:37:「人选定/新建类型后按一次保存,系统按该类型的字段清单把参数值的行铺出来」- spec
:261:提交时chip_type/type_param/profile/parameter四张一起进正式表- 用户原话里也点了名:「选择芯片类型(或创建一个新类型 + 它的核心参数字段,每个带描述与软硬约束)」
为什么 Task 7 做不了(诊断已由控制端核实,属实):
ViewContainer.addRow(packages/ui-kit/src/ViewContainer.tsx:303-306)靠host.querySelector('tr:last-of-type')克隆最后一行,零行时返回false; 而mine:parts.chip_type/mine:parts.type_param每单开局都是零行 —— 加行按钮到不了岸。 顺带:平台在这件事上自相矛盾 ——apps/api/src/app/routes/desk.ts:843那条空表闸的话是 「先在件上加一行填好再提交」,教人做一件 UI 做不到的事。
三条候选路,开工前先定哪一条(各自的代价已初勘,但都要自己复核):
① 件上摆字面量空白行(不走 data-rows,直接写 data-edit + data-col 的行)——
零平台改动。已核实两条支撑机制存在:空行不写(desk.ts:270 一带,filled.length > 0 才落)、
auto 主键由基座续号(type_id 声明 auto,不进表单)。
未解的坎:parts.type_param 是复合主键(type_id + param_name),而新类型的 type_id
要等 chip_type 那一行写下去才铸得出来 —— 跨行的自增键联动基座目前是逐行铸的。先查这条通不通。
② 平台补「原型行」:渲染器对零行的 data-rows 留一行隐藏原型(<tr hidden data-row-proto>),
addRow 找不到 tr:last-of-type 时回落到克隆它。改动小,但动的是 view-render.ts 与
ViewContainer.tsx 两个共享面 —— E04 / I01 都在用,照规矩要全量回归。
顺带能把 desk.ts:843 那句自相矛盾的话变成真的。
③ 配方先种一行骨架:S1 在某颗料没有 chip_type_id 时投一行空 parts.chip_type。
零平台改动,但人若改从下拉挑了已有类型,那行空骨架会让 _missing > 0 一直卡着闸,
得他自己删掉才行 —— 摩擦大且不明显。列在这里是为了记下它被否过,别再想一遍。
Files: 待定(取决于选哪条路)
Interfaces: 消费 Task 7 的 input-supplement 件与 S1 配方的 gateLedgers
- Step 1: 定路线 —— 先查①的跨行自增键联动通不通(
autoKeys在desk.ts:392一带); 通就走①(零平台改动最省),不通就走②(并按「改共享面全量回归」办)。把结论写进报告。 - Step 2: 照选定的路实现,并补测试
- Step 3: 变异验证 —— 拿掉这条能力,证明测试变红
- Step 4:
pnpm gates(看真实退出码)→ 提交
Task 8: 件② difference-review —— 差异评估结论审核(验收工单)
Files:
- Modify:
activities/part-substitution-assessment/src/views/report.ts(新增DIFFERENCE_REVIEW) - Modify:
.../src/manifest.ts(viewTemplates)+case-model.ts(S3_review.view) - Test:
.../__tests__/activity.test.ts
Interfaces:
- Consumes: Task 5 落下的
substitution.difference_item与substitution.relation - Produces: 视图模版
difference-review
绑定契约(spec §6.2):
| 区 | 绑定 | 可改? |
|---|---|---|
| 对决头 | data-slot="original_internal_pn" / candidate_internal_pn;类型名用 <span data-rows="parts.chip_type" data-by="type_id=chip_type_id"> 查表 |
只读 |
| 四态分布带 | 四个 data-slot 取 substitution_difference_item_difference_status_*;条宽用 data-class-of 投影成 class |
只读 |
| 推荐等级大徽章 | data-class-of="recommendation_level" + data-slot |
只读(规则算的) |
| 逐条明细 | <tbody data-rows="mine:substitution.difference_item" data-edit="substitution.difference_item">;difference_item_id 挂 data-col 当钥匙 |
四态(<select data-col="difference_status">)· 影响说明 · 判断依据 · UNKNOWN 去向 可改 |
| 收口四格 | 推荐理由 / 差异摘要 / 风险 / 验证要求,各 data-slot |
只读 |
- Step 1: 写失败的测试
⚠️ 助手名照文件里真有的写(2026-08-29 控制端核正:计划原稿这里写的
viewOf/ASSESSMENT_STEPS在这个测试文件里都不存在)。activity.test.ts用的是:
viewHtml(name)—— 本地定义在:38(manifest.assets.viewTemplates.find(...)?.html ?? '');- 取剧本走
manifest.assets.businessModel.steps,不是 importASSESSMENT_STEPS。这是刻意的:这个文件测的是装配出来的清单(成品),不是
case-model.ts里的源常量 —— 源常量对得上而清单里漏装,正是它要拦的那类错。照它现有的写法来,别新引一套。 (ASSESSMENT_STEPS确实是case-model.ts的真导出,但那是steps.test.ts的用法,别串台。)
describe('件② 差异评估结论审核', () => {
const html = viewOf('difference-review');
it('在 viewTemplates 里,S3 指向它,面孔是审核卡', () => {
expect(html).not.toBe('');
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_review')?.view).toBe('difference-review');
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_review')?.cardVariant).toBe('review');
});
it('逐条明细可改,且**改的是四态**', () => {
expect(html).toContain('data-rows="mine:substitution.difference_item"');
expect(html).toContain('data-edit="substitution.difference_item"');
expect(html).toContain('data-col="difference_status"');
for (const col of ['impact_statement', 'evidence_note', 'unknown_disposition']) {
expect(html).toContain(`data-col="${col}"`);
}
expect(html).toContain('data-col="difference_item_id"');
});
it('**推荐理由 / 摘要 / 风险 / 验证要求只读** —— 让人直接改摘要,等于让他给自己没做的判断盖章', () => {
for (const col of ['recommendation_reason', 'difference_summary', 'verification_requirement']) {
expect(html).toContain(`data-slot="${col}"`);
expect(html).not.toContain(`data-col="${col}"`);
}
expect(html).not.toContain('data-edit="substitution.relation"');
});
it('等级与四态用 data-class-of 上色 —— 判断留在样式表,模版不判', () => {
expect(html).toContain('data-class-of="recommendation_level"');
expect(html).not.toContain('<script');
});
});
- Step 2: 跑一遍,确认它失败
Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件②" > /tmp/red8.txt 2>&1; echo "EXIT=$?"; cat /tmp/red8.txt
Expected: FAIL。整份贴进报告。
- Step 3: 写模版
doc() + ACC1_CSS(验收件那一套)。题头主题色用对话区验收卡同一枚 #0aa294(2026-08-27 用户定:验收工单的题头是验收绿,不是红)。
这张件的收口四格必须只读:它们是系统按 §3.5 规则算出来的,人要改就去改上面那一条四态,系统重算(S3 配方里那两笔投影就是干这个的)。
- Step 4: 跑测试与全量门禁 → 提交
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green8.txt 2>&1; echo "EXIT=$?"; cat /tmp/green8.txt
pnpm gates
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 件② 差异结论审核 —— 可改的是四态,摘要与等级只读
推荐理由 / 摘要 / 风险 / 验证要求都是规则算出来的:让人直接改它们,
等于让他给自己没做的判断盖章。要改就改上面那一条四态,系统重算。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 9: 件③ landing-confirm + 运营中心看板重绑
Files:
- Modify:
.../src/views/report.ts(新增LANDING_CONFIRM) - Modify:
.../src/views/ledger-board.ts(LEDGER_BOARD与LEDGER_BOARD_DATA重绑到新表) - Modify:
.../src/manifest.ts+case-model.ts(T_release.view) - Test:
.../__tests__/activity.test.ts
Interfaces:
- Produces: 视图模版
landing-confirm;看板activity-ledger的data取数声明改绑新表
件③ 全只读,答「这一签下去发生什么」(spec §6.3):进哪几张表各几行(摆人读名)· 结论卡(等级 + 一句话)· 四态分布并把硬约束不符的逐条点名 · 待验项(P-06 的测试条件与后续动作)· 发哪一口。
- Step 1: 写失败的测试
⚠️ 同 Task 8 的那条核正:这个文件里没有
viewOf,用本地的viewHtml(name)(activity.test.ts:38); 取剧本走manifest.assets.businessModel.steps,不是 importASSESSMENT_STEPS。下面的示例代码照这个改。
describe('件③ 落账确认', () => {
const html = viewHtml('landing-confirm');
it('在 viewTemplates 里,T_release 指向它', () => {
expect(html).not.toBe('');
expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>)
.find((s) => s.item === 'T_release')?.view).toBe('landing-confirm');
});
it('**全只读** —— 签之前只核对,不改', () => {
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
expect(html).not.toContain('<script');
});
it('把「进哪几张表各几行」摆出来,用的是基座读数', () => {
for (const reading of ['substitution_relation_rows', 'substitution_difference_item_rows', 'validation_record_rows']) {
expect(html).toContain(`data-slot="${reading}"`);
}
});
it('硬约束不符的逐条点名 —— 签字的人最该看见的就是它', () => {
expect(html).toContain('data-rows="mine:substitution.difference_item"');
/*
* **筛选交给样式表,不是 `data-by`** —— `data-by` 过滤的是「子列 = 外层那一行的父列」,
* 这里没有外层行可依。做法:整表逐行渲出来,行上挂 `data-class-of="judgement_class"`,
* 由 CSS 只显 `.v-hard_mismatch` 那几行(判断留在样式表,模版不判)。
*/
expect(html).toContain('data-class-of="judgement_class"');
});
});
describe('运营中心看板', () => {
const board = manifest.assets.viewTemplates.find((v) => v.name === 'activity-ledger');
it('取数声明改绑新表,不再引已退役的那几张', () => {
const json = JSON.stringify(board?.data ?? {});
for (const gone of ['demand.request', 'parts.document', 'sub_eval.pn_check', 'sub_eval.input_gap', 'sub_eval.verdict']) {
expect(json).not.toContain(gone);
}
for (const kept of ['sub_eval.case', 'substitution.relation', 'substitution.difference_item']) {
expect(json).toContain(kept);
}
});
});
- Step 2–4: 跑红 → 写模版与看板取数声明 → 跑绿 + 门禁 → 提交
Run 红:pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件③" > /tmp/red9.txt 2>&1; echo "EXIT=$?"; cat /tmp/red9.txt
看板的 data 声明形状见《HTML报告生成规范》§4.1(case{ledger,key,prefix} + rows[{as,ledger,by,not,orderBy}]);案号前缀改成 W05-,行圈到:案卷 → 两颗料 → 类型与字段 → 参数对照 → 明细 → 关系 → P-06。
绿 + 门禁 + 提交(信息:feat(w05): 件③ 落账确认与看板重绑 —— 签之前只核对,硬约束不符逐条点名,附两行 trailer)。
Task 10: 退役 —— 两枚工具、两张判定表、技能戳
Files:(2026-08-29 控制端实地核过,原清单漏了四个文件,见下方 Step 1 的实测结果)
- Modify:
apps/api/src/app/tools/first-party.ts—— 摘掉FIRST_PARTY_TOOLS里那两行(:680-681), 连同只服务于它们的实现:compareParameters(:650)·recommend(:656)·compareParametersWith(:592)·ParameterLedger类型(:515);parseParamValue(:393)/describeParsed(:415)先查还有没有别的调用方, 没有才一并摘。留墓志铭。 - Modify:
apps/api/src/app/container.ts——parameterLedger(:424-431)与那一行 handler 覆盖(:434)一并撤, 连ParameterLedger的 import。这一处原清单没有,不撤则工具在容器里照常注册。 - Delete:
apps/api/src/app/tools-w05.ts—— 整份就是这两枚工具的契约定义(W05_TOOLS/W05_TOOL_SAMPLE), 没有第三个消费方。原清单没有。 - Modify:
apps/api/src/app/seed-activities.ts(:19import)与apps/api/src/app/seed-mvp.ts(:15import) ——tools-w05.ts的两个消费方,随它一并清。原清单没有。 - Modify:
apps/api/src/app/__tests__/first-party-substitution.test.ts(见 Step 2 —— 改成退役断言,不是删文件) - Delete:
activities/part-substitution-assessment/src/skills.ts - Modify:
.../src/manifest.ts(assets.skills清空、assets.businessModel.dmn去掉 D01/D02) - Modify:
.../src/constants.ts
Interfaces:
- Consumes: 前九个任务(活动已不再声明这两枚工具)
- Produces: 工具与判定表的退役 + 墓志铭
为什么现在才退:前面每一步都要门禁绿,而工具的既有测试挂在注册上。等活动侧完全不引用了再拆,拆的是死代码而不是活的。
- Step 1: 先确认没有别的消费方
Run:
grep -rn "compare-parameters\|part-substitution-assessment\.recommend" activities apps packages --include='*.ts' \
| grep -v "app/tools/first-party.ts" | grep -v "activities/part-substitution-assessment"
Expected(2026-08-29 控制端实地跑过,不是零命中):恰好这几处,全部在上面的 Files 清单里 ——
container.ts:434 · tools-w05.ts:21,64 · seed-activities.ts:19 · seed-mvp.ts:15 ·
__tests__/first-party-substitution.test.ts 若干。
冒出清单之外的命中就停下来报上来,别硬拆。
- Step 2: 改测试(先描述目标)
把 first-party-substitution.test.ts 里断言这两枚工具可用的用例,改成断言它们已停用且留了墓志铭:
⚠️ 计划原先在这里写错了(2026-08-29 控制端核出并改正)。 原稿写的是
registry.find((t) => t.name.includes(...))?.enabled).toBe(false)—— 三处都不成立: 该文件里没有registry这个名字;FIRST_PARTY_TOOLS(first-party.ts:669)是ReadonlyArray<readonly [string, ToolHandler]>—— 元组数组,元素上没有.name,取它得undefined、 再.includes()当场抛 TypeError;而且本仓的工具压根没有enabled这个概念,退役的既有做法是 摘掉注册 + 留墓志铭(见 quote-archive / quote-intake 的 declarations 头注)。断言改成**「不在注册表里」** —— 它同样拦得住「哪天有人手滑又把它注册回来」:
it('两枚工具已退役 —— 规则活在 S2 的配方里,不再有第二处真源', () => {
const names = FIRST_PARTY_TOOLS.map(([n]) => n);
expect(names).not.toContain('part-substitution-assessment.compare-parameters');
expect(names).not.toContain('part-substitution-assessment.recommend');
});
这个文件其余的用例怎么办:它们逐条测的是 compare-parameters 的比对行为,而那段代码这一步就删了 ——
测试跟着它测的东西一起走,这不是「删测试让门禁变绿」(本仓禁止的是后者)。
把整份文件缩成上面这一条退役断言,文件头注改写成墓志铭:说清这两枚工具做过什么、
规则现在活在哪儿(case-model.ts 的 S2_assess.auto)、以及为什么不留第二份。
- Step 3: 停用并留墓志铭
摘掉注册,不是「标成停用」(2026-08-29 控制端改正:本仓工具没有 enabled 这个概念,见 Step 2 的说明)。
按上面 Files 清单逐处摘干净 —— first-party.ts 的两行注册与只服务于它们的实现、container.ts 的 handler 覆盖
与 parameterLedger、tools-w05.ts 整份、两个 seed 的 import。摘完跑一次 Step 1 的 grep 复查,应当只剩墓志铭里的文字。
在 first-party.ts 原处留一段中文墓志铭,写清:它们算的四态与推荐等级现在活在
activities/part-substitution-assessment 的 S2 配方里(§3.5 的规则表),规则只许有一处真源;
D01 / D02 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 就是同一件事两处各存一份。
删掉 skills.ts(全程无 agent,技能戳没有消费方),manifest.assets.skills 置空,contract.resources.skills 置空。
- Step 4: 跑全量门禁 → 提交
pnpm gates
git add apps/api/src/app/tools apps/api/src/app/container.ts apps/api/src/app/seed-activities.ts \
apps/api/src/app/seed-mvp.ts apps/api/src/app/__tests__ activities/part-substitution-assessment
# tools-w05.ts 是删除,`git add -A apps/api/src/app` 或显式 `git rm` 都行 —— 别漏在工作树里
git commit -m "$(cat <<'EOF'
chore(w05): 两枚工具与两张判定表退役,技能戳删除 —— 规则只许有一处真源
四态与推荐等级现在活在 S2 配方的 when 链里(spec §3.5)。
再留一份 DMN 或一枚工具,就是同一件事两处各存一份,改一处就分叉而没人看得出以哪份为准。
技能戳是给模型看的,而这个活动全程没有 agent。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 11: 装包、落库、五案例 e2e
Files:
- Modify:
tools/e2e-a2.mjs(SUITES.w05,约 550 行起) - Modify:
activities/part-substitution-assessment/src/manifest.ts(samples五条)
Interfaces:
- Consumes: 前十个任务(包已完整)
- Produces: 五条样例 + e2e
w05套件;真库上跑通
⚠️ 这一步会动共享库,开工前必须先过一道人
开发实例(:8787)与稳定版实例(:8790)共用
jcai_wos库,而稳定版跑的是分支之前的代码。 装 W05@2.0.0 会把新清单写进sys.activity_package,两个实例都读它 —— 而旧代码的planStepSchema要求guide非空(R8 那条放宽只在本分支上),它解析不了这份新清单。用户已经定了(2026-08-29,原话「走查期间先停掉稳定版」)——走第 ① 条。 曾摆过的三条路留在这儿备查:①走查期间停稳定版;②另开一个库跑(代价是种子要重播); ③明知有风险照装(稳定版在此期间用不了 W05)。
执行者不要自己去停 —— 停稳定版是控制端的事(它是本工作树之外的副作用)。 开工前先确认
lsof -iTCP:8790 -sTCP:LISTEN没有监听;还在监听就停下来报上来,别自己动手、也别绕过去装。 确认停了之后,pnpm db:migrate/activities:install照跑(db:seed仍需另行确认 —— 它会重播治理台配置)。
- Step 1: 写五条样例
manifest.samples 五条,名 ≤14 字(超过 14 字运营端只显示「样例 N」,等于没名字),第一条是最长的那条路(测试台落地即预填的是它):
samples: [
{ name: '候选缺库·新建类型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
{ name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
{ name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } },
{ name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
{ name: '两颗都缺库', input: { originalInternalPn: 'CN-DCDC-32V', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
],
包内自测补一条:样例名都 ≤14 字、第一条是缺库那条。
- Step 2: 重写 e2e 的
w05套件
tools/e2e-a2.mjs 里 SUITES.w05 整体换掉。照该文件既有套件的形状写(先读 e04 / i01 两个套件,用文件里真有的键名),要覆盖:
-
零卡直分析那条:断言一张数据工单都没发(第二条样例),直接走到审核卡;
-
缺库那几条:数据工单派出后,件上按地址保存(
buildEdits按data-edit地址 +data-col列名), 空手交卷必须吃 400(提交闸真拦),补齐后才过; -
类型不一致那条:断言
gate_reason里点了名、且补不成同类型时提交仍被拦(这条是硬拦,不许绕过去); -
审核卡:单选项「确认通过」+ 人工验收清单要勾全才能通过(
checked ⊇ checklist,不勾全应当被拒); -
放行:终局验收卡 →
released→ 发口assessment-released; -
模型调用恰一次:从任务留痕里数
analyze那一笔(全套只该有一次)。 -
Step 3: 装包并核库
在 Step 0 的确认拿到之后:
pnpm db:migrate
pnpm db:seed
pnpm activities:install
然后用 SQL 核库里的清单版本,不看脚本的话(同版重装是 no-op,脚本照样打印「已装」—— 踩过):
node -e "const{Client}=require('pg');(async()=>{const c=new Client({connectionString:process.env.DATABASE_URL});await c.connect();const r=await c.query(\"select version, enabled from sys.activity_package where activity_id like '%part-substitution%' order by installed_at desc limit 3\");console.log(r.rows);await c.end();})()"
Expected: 最新一行是 2.0.0。不是就先查为什么(多半是版本号没升,或另一个会话抢先占了号)。
- Step 4: 跑 e2e
JCAI_E2E_LAND_MASTER=1 node tools/e2e-a2.mjs w05 > /tmp/e2e-w05.txt 2>&1; echo "EXIT=$?"; tail -60 /tmp/e2e-w05.txt
验收线:5/5 released · 发口全对 · 提交闸每单真拦(空手交卷吃 400)· 模型调用恰 1 次/单 · 配方零出错。 (「行动被拒 0」这条对无 agent 的活动自动成立,改数模型调用次数。)
跑完清运行数据:pnpm --filter @app/api reset-runs。
- Step 5: 提交
git add tools/e2e-a2.mjs activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
test(w05): 五案例 e2e —— 零卡直分析 / 缺库 / 类型不一致硬拦 / 两颗都缺库
验收线:5/5 released、发口全对、提交闸每单真拦、模型调用恰 1 次。
「行动被拒 0」对无 agent 的活动自动成立,所以改数模型调用次数 —— 它才是这次要守的那个数。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
Task 12: 浏览器走查与收口
Files:
- Modify:
docs/活动开发规范.md·docs/debt.md·CLAUDE.md - Delete:
docs/数据域字段说明-W05.md里已不存在的那几张表的段落(或整篇重写)
Interfaces:
- Consumes: Task 11 装好的包
- Produces: 一次真人路走查的账 + 文档收口
这一步的交付物是「亲眼看见它跑通」 —— 脚本 e2e 与浏览器走查逮到的缺陷不重合: 脚本逮机制(它每张卡都先空手交一次、每个岔路口按序代答),浏览器逮人看见什么、点得动什么。 本仓两条线各自逮到过对方漏掉的一整批。
- Step 1: 走最长的那条路
用AI 服务账号起单(绝不派到用户工作台),样例选「候选缺库·新建类型」,在浏览器里走完:
- 运营中心 → 活动 → ACT-WORK-05 → 一键启动,两格下拉各选/填一个;
- 工作台收到数据工单:件上红带点名「还差什么」;
- 类型下拉里新建一个类型,加它的核心参数字段(名字 / 说明 / 硬软约束);按保存;
- 确认参数值那几行自己出现了(这是「保存即铺格」——它是这套设计最容易出问题的一环);
- 填齐参数值 → 提交(先试一次空手提交,确认闸真拦并点名缺哪一格);
- 收到审核卡:件上逐条摆着四态与影响;就地改一条四态,保存,确认推荐等级跟着变了;
- 勾全人工验收清单 → 确认通过;
- 收到落账验收卡:件上摆着「进哪几张表各几行」;签字 →
released; - 工作成果里两份定稿各是各的快照。
逐条记账:每一步看到什么、有没有与设计不符的地方。看到不对就停下来记,别顺手改 —— 改动要走任务与评审。
- Step 2: SQL 复核落账
node -e "const{Client}=require('pg');(async()=>{const c=new Client({connectionString:process.env.DATABASE_URL});await c.connect();for(const t of ['substitution__relation','substitution__difference_item','validation__record','sub_eval__case']){const r=await c.query(\`select count(*) from \${t}\`);console.log(t, r.rows[0].count);}await c.end();})()"
核三件:①明细行数 = 该类型的核心参数字段数;②关系一行,recommendation_level 与四态分布对得上;
③P-06 一行、validation_status = 'not_started'(本活动不虚构实测结果)。
-
Step 3: 收口文档
-
《活动开发规范》:在 §4.6 后补一小节,把这个活动作为第一个全 auto 的范例点名(四步、零 guide、AI 一次), 并写下这次真正踩到的坑(至少:
gate_reason != ""那个未绑定名陷阱、chip_type_id顶层同名冲突要对准一张表 lookup)。 -
docs/debt.md:记下这一轮欠着的(至少:{joinRows}未实现所以验证要求不逐条列参数名; 类型字典的去重与合并没做,同一类型建两遍不会被拦)。 -
CLAUDE.md现状快照加一条:ACT-WORK-05 已重写为四步全 auto,写清「AI 出场恰一次、行由字典决定不由它决定、 推荐等级由规则算」,以及五案例 e2e 与浏览器走查的实测账。 -
Step 4: 最后一遍门禁并提交
pnpm gates
git add docs CLAUDE.md
git commit -m "$(cat <<'EOF'
docs: ACT-WORK-05 重写收口 —— 四步全 auto 的第一个范例
规范补下这一轮真踩到的两个坑:S-FEEL 里未绑定的名字 `!= ""` 求值为 true
(拿 gate_reason 当发卡条件会在一切正常时把卡发出去);
顶层同名字段以「后写的赢」为准,取值要对准一张表 lookup 而不是赌哪张表最后写。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"
验收线
pnpm gates六道全绿(以退出码验,不许接管道)。- e2e
w05五案例 5/5 released、发口全对、提交闸每单真拦、模型调用恰 1 次/单。 - 浏览器走完最长那条路:新建类型 → 保存 → 参数格自己出现 → 填齐提交 → 审核卡就地改四态 → 等级跟着变 → 勾全清单 → 放行 → released。
- 库里核过:明细行数 = 该类型字段数;关系的推荐等级与四态分布对得上;P-06 一行且
not_started。 - 活动包里没有一处
guide、没有一件工具、没有一面记账旗。