# 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) 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) 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>; 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) 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 = ` update 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 update substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and substitution_relation_rows = 1 and validation_record_rows = 1 update substitution_relation_rows = 1 and substitution_relation_missing = 0 and validation_record_missing = 0 `; ``` > **`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) 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) 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) 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) 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),绑定契约如下** —— 模版只取值、不判断: | 块 | 绑定 | 说明 | |---|---|---| | ① 要评估什么 | `` | 两行(行由系统铺,**人不加行**)。每行摆 `internal_pn`(主键,**必挂 `data-col`** —— 它不是编辑面,是行的钥匙)、在库状态徽(`data-class-of="exists_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 = viewHtml('difference-review'); it('在 viewTemplates 里,S3 指向它,面孔是审核卡', () => { expect(html).not.toBe(''); const s3 = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string; cardVariant?: string }>) .find((s) => s.item === 'S3_review'); expect(s3?.view).toBe('difference-review'); expect(s3?.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(' /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) 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(' { 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` —— **元组数组,元素上没有 `.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) 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) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet EOF )" ``` --- ### Task 12: 浏览器走查与收口 > **收口时顺手核对的文档口径**(2026-08-29 从 Task 10 评审转来,Ruling H25): > `activities/part-substitution-assessment/src/index.ts:5` 的头注仍写 > 「案例模型 + 说明书 + 步骤剧本 + **三条技能** + 台账声明 + **五张 HTML 报告模版**」—— > 而重写之后是 **0 条技能、3 张件**。纯注释、零运行时影响,但**注释撒谎也是撒谎**。 > 收口时一并核一遍这个包里还有没有别处写着旧数目/旧机制的说明文字。 **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) 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`、没有一件工具、没有一面记账旗**。