This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/superpowers/plans/2026-08-29-act-work-05-rewrite.md
T
xcsandClaude Opus 5 abe92e0531 docs(w05): Task 8/9 的测试助手名核正 —— activity.test.ts 里没有 viewOf 也不 import ASSESSMENT_STEPS
计划原稿让 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
2026-08-29 19:16:14 +08:00

2253 lines
124 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 笔,未合并)。本计划**建在它之上**:
```bash
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_id` integer 主键 · `type_name` · `description`)与 `parts__type_param`(`type_id` + `param_name` 复合主键 · `param_desc` · `constraint_type` · `param_unit`);`parts.profile` 追加 `chip_type_id` integer 列;种子数据:两个类型、七 + 五个核心参数字段、三颗料的 `chip_type_id`、一颗新 LDO 料 `AMS1117-3.3` 及其参数行
- [ ] **Step 1: 先读现状,别猜**
Run:
```bash
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`,先在文件里加两个局部帮手:
```ts
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);
```
```ts
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` **之前**加(字典要先于引用它的表):
```ts
{
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` 的字段清单里加一行:
```ts
f('chip_type_id', 'integer', '芯片类型'),
```
- [ ] **Step 5: 加种子行**
在 `parts.parameter` 的种子行**之前**加两个类型与十二条字段(`type_id` 用 1 / 2 两个字面值,与既有种子的写法一致):
```ts
{ 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 用例的唯一素材,没有它那条路测不了**):
```ts
{
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:
```bash
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: 提交**
```bash
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` 里「台账声明」那一组整体换成:
```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 为准**(那几节把每一列都列全了),并把上面测试里断言到的每一条都满足。三处要写注释说清「为什么」:
```ts
/*
* **约束类型不在这里** —— 它长在 `parts.type_param`(按类型标一次)。
* 从前每颗料各标一次,同一个参数在两颗料上标法不一致就成了可能;
* 而比对是逐参数对着比的,两边约束档不同,判出来的四态就没有意义。
*/
```
```ts
/*
* **`distinct`**:「两颗料是不是同一个类型」从此是基座数得出来的数
* (`sub_eval_part_distinct_chip_type_id`)—— 不同取值恰好 1 种才算一致。
* 这一条是 S1 收步与 S2 入场的硬门:两边字段清单不是同一套,逐项比对无从谈起。
*/
```
```ts
/*
* **`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-order` 1/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:
```bash
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: 提交**
```bash
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` 整体换成:
```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**
```ts
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 &gt; 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`)**
```ts
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:
```bash
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: 提交**
```bash
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` 不能凭空判,
**判「在库」就必须拿得出一个基座查得到的料号**:
```ts
/*
* **在库核对依据**:判 `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 那条「案卷两张」用例里有这一行断言(没有就补):
```ts
expect(colOf('sub_eval.part', 'found_pn_ref')?.refersTo).toBe('parts.profile.internal_pn');
```
- [ ] **Step 2: 写失败的测试**
追加到 `steps.test.ts`:
```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` 换成:
```ts
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:
```bash
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: 提交**
```bash
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_param` `each` 出来,AI 的出参只是拿 `param_name`
> 去 `lookup` 的一张查表;它漏一条,那一格就是空,`required` 立刻变成 `_missing > 0`,闸与放行门当场拦住。
> 写成 `from each(AI 出参)` 的话,它少给两条就少两行,**而每一行看着都合规**。
> ② **值是多少一个字都不重述** —— 基座读得到的事实不让模型转述(本仓踩过:模型念出的「输入上限 32V」
> 是示例里的数,不是这一单的)。
> ③ **推荐等级、风险、摘要、推荐理由全由规则与模板拼** —— 于是永远不会出现「有硬约束不符却写着推荐」。
- [ ] **Step 1: 写失败的测试**
追加到 `steps.test.ts`:
```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`,不歧义。
```ts
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:
```bash
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: 提交**
```bash
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`:
```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` 之前,各起一个名字:
```ts
/*
* **关系与验证记录的投影,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 的配方**
```ts
/* 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: '差异结论已送审,请在件上逐条核对。' },
],
},
```
```ts
/* 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:
```bash
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: 提交**
```bash
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`:
```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:
```bash
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: 提交**
```bash
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`**,不是 import `ASSESSMENT_STEPS`。
>
> 这是刻意的:这个文件测的是**装配出来的清单**(成品),不是 `case-model.ts` 里的源常量 ——
> 源常量对得上而清单里漏装,正是它要拦的那类错。**照它现有的写法来,别新引一套。**
> (`ASSESSMENT_STEPS` 确实是 `case-model.ts` 的真导出,但那是 `steps.test.ts` 的用法,别串台。)
```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: 跑测试与全量门禁 → 提交**
```bash
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`**,不是 import `ASSESSMENT_STEPS`。下面的示例代码照这个改。
```ts
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`(`:19` import)与 `apps/api/src/app/seed-mvp.ts`(`:15` import)
—— `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:
```bash
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 头注)。
>
> 断言改成**「不在注册表里」** —— 它同样拦得住「哪天有人手滑又把它注册回来」:
```ts
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: 跑全量门禁 → 提交**
```bash
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」,等于没名字),**第一条是最长的那条路**(测试台落地即预填的是它):
```ts
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 的确认拿到之后**:
```bash
pnpm db:migrate
pnpm db:seed
pnpm activities:install
```
然后**用 SQL 核库里的清单版本,不看脚本的话**(同版重装是 no-op,脚本照样打印「已装」—— 踩过):
```bash
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**
```bash
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: 提交**
```bash
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 服务账号**起单(绝不派到用户工作台),样例选「候选缺库·新建类型」,在浏览器里走完:
1. 运营中心 → 活动 → ACT-WORK-05 → 一键启动,两格下拉各选/填一个;
2. 工作台收到**数据工单**:件上红带点名「还差什么」;
3. 类型下拉里**新建一个类型**,加它的核心参数字段(名字 / 说明 / 硬软约束);**按保存**;
4. **确认参数值那几行自己出现了**(这是「保存即铺格」——它是这套设计最容易出问题的一环);
5. 填齐参数值 → 提交(先试一次**空手提交**,确认闸真拦并点名缺哪一格);
6. 收到**审核卡**:件上逐条摆着四态与影响;**就地改一条四态**,保存,**确认推荐等级跟着变了**;
7. 勾全人工验收清单 → 确认通过;
8. 收到**落账验收卡**:件上摆着「进哪几张表各几行」;签字 → `released`;
9. 工作成果里两份定稿各是各的快照。
**逐条记账**:每一步看到什么、有没有与设计不符的地方。**看到不对就停下来记,别顺手改** —— 改动要走任务与评审。
- [ ] **Step 2: SQL 复核落账**
```bash
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: 最后一遍门禁并提交**
```bash
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
)"
```
---
## 验收线
1. `pnpm gates` 六道全绿(以退出码验,不许接管道)。
2. **e2e `w05` 五案例 5/5 released**、发口全对、提交闸每单真拦、**模型调用恰 1 次/单**。
3. **浏览器走完最长那条路**:新建类型 → 保存 → **参数格自己出现** → 填齐提交 → 审核卡就地改四态 → **等级跟着变** → 勾全清单 → 放行 → released。
4. 库里核过:明细行数 = 该类型字段数;关系的推荐等级与四态分布对得上;P-06 一行且 `not_started`。
5. 活动包里**没有一处 `guide`、没有一件工具、没有一面记账旗**。