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

125 KiB

ACT-WORK-05 差异评估 · 活动重写 实施计划(第二份)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把 activities/part-substitution-assessment 从「九步、两枚工具、全程 agent 驱动」重写成「四步、零工具、零 guide、AI 出场恰好一次」—— 每一步由引擎按声明式配方确定性地做完。

Architecture: 活动包只是数据(清单 + 案例模型 + HTML 模版),平台不执行它的代码。四步各写一份 PlanStep.auto 配方(五动词);唯一的 AI 出场是 S2 的一笔 analyze —— 系统把两颗料的核心参数逐条组装好交给它,它只答「这一条算什么、为什么」,值是多少一个字都不重述。推荐等级、风险、摘要、验证要求全由规则与模板串算,不让 AI 编。

Tech Stack: TypeScript(exactOptionalPropertyTypes)· zod 契约 · vitest · S-FEEL 判据 · 无脚本沙箱 HTML 模版

Spec: docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md —— 本计划实现它的 §3 / §4 / §6 / §7 / §8 / §9 / §10(§5 平台机制已在 feat/deterministic-steps 上落地并通过全支终审)

前置(已完成,可直接用)

  • PlanStep.auto 配方:五动词 project / analyze / card / submit / say;八式表达式;四种地址;嵌套两层封顶
  • guide 允许为空当且仅当这一步声明了 auto
  • 卡答复即收步(前提:无 doneWhen + 卡发过 + 配方会发卡 + 这一步没有 guide)
  • 装包静态审:台账地址 / 列名 / 模型名 / 视图名 / submit 要有人工验收清单 / when 的 S-FEEL / project.fields 必须含目标表主键列 / card 必须有 basis / prune 与 humanWrites 同表报 warn
  • 写配方怎么写:《活动开发规范》§4.6 —— 动手前先读它

Global Constraints

  • 五道门禁以退出码验,不许接管道(cmd | tail 的退出码是 tail 的):pnpm gates。
  • 契约变更四步:改 packages/contracts → depcruise 反查下游 → 同步 CONTRACT.md → pnpm api:check --update && git add api-report。
  • 禁止清单:不许 any / as unknown as / @ts-ignore;生产代码不许 as never(测试文件里是本仓既有做法)。
  • 活动包只准 import @sdk / @contracts / @sfeel —— depcruise 门禁会查。
  • 机检只读基座数:判据的任何一边都不许是活动自己写的汇总列。这条在本仓被违反过六次,每次都是同一种事故。
  • 判据不许编一个数不出来的数:_rows / _missing / tally / _distinct_x 数的都是本单草稿写下的行,不是整张表。「本类型有几个字段」不是基座数,别写进 doneWhen。
  • 触达世界的项不给 doneWhen —— 给了等于给引擎一把替人签字的钥匙。
  • 模版不许算、不许判:只取值;判断留在样式表(data-class-of)与基座。
  • 注释用中文,写「为什么这么做 / 踩过什么坑」,不写「这是什么」。
  • 反例一律不引原话 —— 关思考的模型不读「别说」的否定框,只认框里现成的句子。要引原话就引正例。
  • 安装恒新增:改了内容必须升 packageVersion,同版重装是 no-op(静默丢失,踩过)。装完看库,不看脚本的话。
  • 试跑一律用 AI 服务账号,绝不派到用户工作台;测完 pnpm --filter @app/api reset-runs。
  • 不要跑 ./stable.sh update(稳定版与开发共用 jcai_wos 库,别动它)。
  • 提交信息中文,末尾附:
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet

前置:分支

第一份计划的成果在 feat/deterministic-steps 上(38 笔,未合并)。本计划建在它之上:

git switch feat/deterministic-steps   # 若已在别处,先确认它是 HEAD
git switch -c feat/w05-rewrite

做完由主支发起合并(先合机制那条,再合这条),不要反过来把主支合进 feature。


文件结构

文件 职责 动作
apps/api/src/app/seed-mvp-data.ts 平台基线:物理表 + 种子数据 改:加两张新表的物化 + 类型/字段/LDO 料的种子行
activities/part-substitution-assessment/src/constants.ts 域名、规则版本 改:退役两个工具名常量
.../src/manifest.ts 清单:契约 / 台账声明 / 判据 / 样例 / 视图 重写
.../src/case-model.ts CMMN + 说明书 + 四步剧本(含配方)+ 阶段 重写
.../src/views/target-style.ts 目标件抽出来的样式表 改:保留可复用的,删无主的
.../src/views/report.ts 三张件 重写
.../src/views/ledger-board.ts 运营中心看板 改:重绑到新表
.../src/skills.ts 技能戳(给模型看的) 删:全程无 agent,它没有消费方
.../__tests__/activity.test.ts 清单合规 / 授权面对齐 重写
.../__tests__/steps.test.ts 剧本与配方口径钉死 重写
tools/e2e-a2.mjs e2e 套件 改:SUITES.w05(约 550 行起)重写成五案例
apps/api/src/app/tools/first-party.ts 一方工具 改:两枚工具退役并留墓志铭

Task 1: 两张新字典表 —— 物化 + 种子

Files:

  • Modify: apps/api/src/app/seed-mvp-data.ts
  • Test: apps/api/src/app/__tests__/seed-mvp-data.test.ts

Interfaces:

  • Produces: 物理表 parts__chip_type(type_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:

grep -n "domain: 'parts'" apps/api/src/app/seed-mvp-data.ts | head
sed -n '140,200p' apps/api/src/app/seed-mvp-data.ts

看清两件事:①表的物化清单长什么样(f(name, type, label, primaryKey) 帮手、address: { domain, object } 的结构);②种子行长什么样({ address, key, values })。照它的形状写,别另造一套。

  • Step 2: 写失败的测试

追加到 apps/api/src/app/__tests__/seed-mvp-data.test.ts 末尾。帮手名已查实:该文件从 ../seed-mvp-data.ts 引 MASTER_DOMAINS / MASTER_OBJECTS / MASTER_SAMPLE_ROWS,先在文件里加两个局部帮手:

const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object);
const seedRowsOf = (domain: string, object: string) =>
  MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object);
describe('芯片类型字典(W05 重写)', () => {
  it('两张新表都物化了,主键形状对', () => {
    const chipType = tableOf('parts', 'chip_type');
    expect(chipType?.fields.find((f) => f.name === 'type_id')).toMatchObject({ type: 'integer', primaryKey: true });
    expect(chipType?.fields.map((f) => f.name)).toEqual(expect.arrayContaining(['type_id', 'type_name', 'description']));

    const typeParam = tableOf('parts', 'type_param');
    /* 复合主键:一个类型下多个参数名 —— 主键少一半,同一类型的第二个参数就会覆盖第一个 */
    expect(typeParam?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
    expect(typeParam?.fields.map((f) => f.name)).toEqual(
      expect.arrayContaining(['type_id', 'param_name', 'param_desc', 'constraint_type', 'param_unit']),
    );
  });

  it('P-08 上有 chip_type_id,类型与字典的主键对得上(integer)', () => {
    expect(tableOf('parts', 'profile')?.fields.find((f) => f.name === 'chip_type_id')).toMatchObject({ type: 'integer' });
  });

  it('种子:两个类型,DC-DC 七个核心参数字段、LDO 五个', () => {
    const types = seedRowsOf('parts', 'chip_type');
    expect(types.map((r) => r.values['type_name']).sort()).toEqual(['DC-DC 降压稳压器', 'LDO 线性稳压器']);

    const dcdc = types.find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
    const params = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === dcdc);
    expect(params.map((r) => r.values['param_name'])).toEqual([
      '输入电压范围', '输出电压', '输出电流', '开关频率', '静态电流', '工作温度', '使能阈值',
    ]);
    /* 约束类型搬到字典上了 —— 同一个参数在两颗料上标法不一致这件事从结构上消失 */
    expect(params.find((r) => r.values['param_name'] === '静态电流')?.values['constraint_type']).toBe('soft');
    expect(params.find((r) => r.values['param_name'] === '输入电压范围')?.values['constraint_type']).toBe('hard');
    /* 每个字段都要有说明 —— AI 分析时读的就是它 */
    expect(params.every((r) => String(r.values['param_desc'] ?? '') !== '')).toBe(true);

    const ldo = types.find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
    expect(seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo)).toHaveLength(5);
  });

  it('三颗既有料都归了 DC-DC 类型,新播一颗 LDO 供「类型不一致」那条用例用', () => {
    const dcdc = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
    const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
    const parts = seedRowsOf('parts', 'profile');
    for (const pn of ['LM2596S-5.0', 'LM2596S-ADJ']) {
      expect(parts.find((r) => r.key['internal_pn'] === pn)?.values['chip_type_id']).toBe(dcdc);
    }
    expect(parts.find((r) => r.key['internal_pn'] === 'AMS1117-3.3')?.values['chip_type_id']).toBe(ldo);
  });

  it('AMS1117-3.3 的参数行按 LDO 的字段清单播齐 —— 它要能被真拿来比', () => {
    const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
    const names = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo).map((r) => r.values['param_name']);
    const rows = seedRowsOf('parts', 'parameter').filter((r) => r.key['internal_pn'] === 'AMS1117-3.3');
    expect(rows.map((r) => r.key['param_name']).sort()).toEqual([...names].sort());
  });
});
  • Step 3: 跑一遍,确认它失败

Run: pnpm exec vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts > /tmp/red1.txt 2>&1; echo "EXIT=$?"; cat /tmp/red1.txt Expected: FAIL —— 两张表不存在。把 /tmp/red1.txt 整份贴进报告,不要裁剪。

  • Step 4: 加两张表的物化

在 seed-mvp-data.ts 的物化清单里,parts.profile 之前加(字典要先于引用它的表):

  {
    address: { domain: 'parts', object: 'chip_type' },
    kind: 'master',
    label: '芯片类型',
    fields: [
      /*
       * **整数自增主键**:人在件上「+新建类型」时这一格留空,由基座按「现有最大 + 1」续号
       * (与 I01 的序号主键同一条路)。**空着的自增列不是身份线索,是「给我续个号」。**
       */
      f('type_id', 'integer', '类型ID', true),
      f('type_name', 'text', '类型名称'),
      f('description', 'text', '类型说明'),
    ],
  },
  {
    address: { domain: 'parts', object: 'type_param' },
    kind: 'master',
    label: '芯片类型核心参数字段',
    fields: [
      f('type_id', 'integer', '所属类型', true),
      f('param_name', 'text', '核心参数名', true),
      /* **AI 分析时读的就是这一列** —— 参数名本身说不清「它为什么要紧」 */
      f('param_desc', 'text', '参数说明'),
      /*
       * 约束类型从 `parts.parameter`(每颗料各标一次)搬到这里(按类型标一次)——
       * 「同一个参数在两颗料上标法不一致」这件事从此在结构上不可能发生。
       */
      f('constraint_type', 'text', '约束类型'),
      f('param_unit', 'text', '参考单位'),
    ],
  },

并在 parts.profile 的字段清单里加一行:

    f('chip_type_id', 'integer', '芯片类型'),
  • Step 5: 加种子行

在 parts.parameter 的种子行之前加两个类型与十二条字段(type_id 用 1 / 2 两个字面值,与既有种子的写法一致):

  { address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 1 }, values: { type_id: 1, type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器:靠电感储能与高频开关把输入电压降到目标输出,效率高、发热小,外围需要电感与输出电容。' } },
  { address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 2 }, values: { type_id: 2, type_name: 'LDO 线性稳压器', description: '低压差线性稳压器:靠调整管压降稳压,外围简单、噪声低,压差与电流之积全部转成热耗。' } },

  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输入电压范围' }, values: { type_id: 1, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电压' }, values: { type_id: 1, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电流' }, values: { type_id: 1, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '开关频率' }, values: { type_id: 1, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '静态电流' }, values: { type_id: 1, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '工作温度' }, values: { type_id: 1, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '使能阈值' }, values: { type_id: 1, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' } },

  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输入电压范围' }, values: { type_id: 2, param_name: '输入电压范围', param_desc: '允许的输入直流电压区间。', constraint_type: 'hard', param_unit: 'V' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电压' }, values: { type_id: 2, param_name: '输出电压', param_desc: '固定版给定值;可调版需外围分压设定。', constraint_type: 'hard', param_unit: 'V' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电流' }, values: { type_id: 2, param_name: '输出电流', param_desc: '满载持续输出能力;线性稳压器受热耗限制,实际可用电流常低于标称。', constraint_type: 'hard', param_unit: 'A' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '压差' }, values: { type_id: 2, param_name: '压差', param_desc: '维持稳压所需的最小输入输出电压差。压差不够就稳不住,输出跟着输入掉。', constraint_type: 'hard', param_unit: 'V' } },
  { address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '工作温度' }, values: { type_id: 2, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。', constraint_type: 'hard', param_unit: '℃' } },

给两颗既有料的 parts.profile 种子行各加 chip_type_id: 1;再播一颗 LDO 主记录与它的五行参数(这一颗是「类型不一致 → 硬拦」那条 e2e 用例的唯一素材,没有它那条路测不了):

  {
    address: { domain: 'parts', object: 'profile' },
    key: { internal_pn: 'AMS1117-3.3' },
    values: {
      internal_pn: 'AMS1117-3.3', chip_type_id: 2, brand_name: 'AMS',
      manufacturer_name: 'Advanced Monolithic Systems', specification_description: '3.3V 固定输出低压差线性稳压器,SOT-223',
      lifecycle_status: 'active', datasheet_ref: 'https://example.invalid/ams1117', datasheet_version: 'Rev.C',
    },
  },
  { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围', param_value: '4.75~15', param_unit: 'V', param_note: '最低受压差限制' } },
  { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电压' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电压', param_value: '3.3', param_unit: 'V', param_note: '固定版' } },
  { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电流' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电流', param_value: '1', param_unit: 'A', param_note: '受散热限制' } },
  { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '压差' }, values: { internal_pn: 'AMS1117-3.3', param_name: '压差', param_value: '1.3', param_unit: 'V', param_note: '满载时' } },
  { address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '工作温度' }, values: { internal_pn: 'AMS1117-3.3', param_name: '工作温度', param_value: '-40~+125', param_unit: '℃', param_note: '' } },

既有 parts.parameter 种子行里的 constraint_type 全部删掉 —— 它搬到字典上了,留着会让「同一个参数两处各有一个约束」重新可能。

  • Step 6: 跑测试与全量门禁

Run:

pnpm exec vitest run apps/api/src/app/__tests__/seed-mvp-data.test.ts > /tmp/green1.txt 2>&1; echo "EXIT=$?"; cat /tmp/green1.txt
pnpm gates

Expected: 前者 5 条全 PASS(整份贴进报告);pnpm gates 退出码 0。

⚠️ 现在先不要跑 pnpm db:migrate / db:seed —— 那会动开发库,而开发库与稳定版共用。 落库统一放到 Task 12(装包与真库跑通)一次做,那时才有完整的活动包配套。

  • Step 7: 提交
git add apps/api/src/app/seed-mvp-data.ts apps/api/src/app/__tests__/seed-mvp-data.test.ts
git commit -m "$(cat <<'EOF'
feat(seed): 芯片类型字典两张表 —— 约束类型从「每颗料各标一次」搬到「按类型标一次」

两颗料是不是用同一套参数名、同一套软硬约束,从此是结构保证而不是措辞叮嘱:
字段清单挂在类型上,一颗料关联一个类型。此前靠 parts.parameter.param_name 的
自指引用墙硬咬(措辞拦了三次没拦住才砌的),那堵墙可以退休了。
另播一颗 LDO(AMS1117-3.3)—— 它是「两颗料类型不一致 → 硬拦」那条路的唯一素材。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 2: 清单 —— 台账声明与契约

Files:

  • Modify: activities/part-substitution-assessment/src/manifest.ts(declarations.ledger 与 contract 两段整体重写)
  • Modify: activities/part-substitution-assessment/src/constants.ts
  • Test: activities/part-substitution-assessment/__tests__/activity.test.ts

Interfaces:

  • Consumes: Task 1 物化的 parts.chip_type / parts.type_param / parts.profile.chip_type_id
  • Produces: 七张台账的声明(两张字典 + P-08 主/参数 + 案卷两张 + P-11 主/明细 + P-06)、启动两参、单一出口 assessment-released、九条验收判据、预算

这一步做完包应当仍能 typecheck、包内自测绿。 案例模型还是旧的九步 —— 下一个任务才换, 所以这一步先不要动 case-model.ts;manifest 里对旧步骤的引用(ASSESSMENT_STEPS 等)原样留着。 若旧剧本引用了本步删掉的台账地址导致包内自测红,把那几条断言临时注掉并在报告里点名,Task 3 会补回。

  • Step 1: 写失败的测试

把 __tests__/activity.test.ts 里「台账声明」那一组整体换成:

const ledgerOf = (address: string) =>
  manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address);
const colOf = (address: string, name: string) => ledgerOf(address)?.fields.find((f) => f.name === name);

describe('台账声明', () => {
  it('恰好九条声明(七个业务对象,P-08 与 P-11 各是主表 + 子表)', () => {
    expect(manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`).sort()).toEqual([
      'parts.chip_type', 'parts.parameter', 'parts.profile', 'parts.type_param',
      'sub_eval.case', 'sub_eval.part',
      'substitution.difference_item', 'substitution.relation', 'validation.record',
    ].sort());
  });

  it('类型字典:type_id 是自增主键 —— 人在件上新建类型时留空,由基座续号', () => {
    expect(colOf('parts.chip_type', 'type_id')).toMatchObject({ primaryKey: true, type: 'integer', auto: true });
    expect(colOf('parts.chip_type', 'type_name')?.required).toBe(true);
  });

  it('字段字典:复合主键 + 约束类型长在这里(不在每颗料上)', () => {
    expect(ledgerOf('parts.type_param')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
    expect(colOf('parts.type_param', 'type_id')?.refersTo).toBe('parts.chip_type.type_id');
    expect(colOf('parts.type_param', 'param_desc')?.required).toBe(true);
    expect(colOf('parts.type_param', 'constraint_type')).toMatchObject({ required: true, tally: ['hard', 'soft'] });
  });

  it('P-08:类型必填并引用字典;createOnly 撤掉 —— 在库老料要能补上类型这一格', () => {
    expect(colOf('parts.profile', 'chip_type_id')).toMatchObject({ required: true, type: 'integer', refersTo: 'parts.chip_type.type_id' });
    expect(ledgerOf('parts.profile')?.createOnly).toBeUndefined();
    /* 封装与工艺本来就是「核心参数」—— 它们该以类型字段的身份出现在字典里,不在主记录上另开一列 */
    for (const gone of ['package', 'process_technology', 'eol_pcn_status', 'application_scenarios']) {
      expect(colOf('parts.profile', gone)).toBeUndefined();
    }
  });

  it('参数子表:约束类型与自指引用墙都退休了', () => {
    expect(colOf('parts.parameter', 'constraint_type')).toBeUndefined();
    expect(colOf('parts.parameter', 'param_name')?.refersTo).toBeUndefined();
    expect(colOf('parts.parameter', 'param_name')?.distinct).toBeUndefined();
    expect(colOf('parts.parameter', 'param_value')?.required).toBe(true);
    expect(ledgerOf('parts.parameter')?.createOnly).toBeUndefined();
  });

  it('案卷两张:part 一单两行(配方遍历的唯一来源)、case 一单一行', () => {
    expect(ledgerOf('sub_eval.part')?.caseKey).toBe('eval_ref');
    expect(ledgerOf('sub_eval.part')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref', 'internal_pn']);
    /* 「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种 */
    expect(colOf('sub_eval.part', 'chip_type_id')).toMatchObject({ required: true, distinct: true });
    expect(colOf('sub_eval.part', 'exists_status')?.tally).toEqual(['found', 'absent']);
    expect(ledgerOf('sub_eval.case')?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref']);
  });

  it('P-11 明细带合成列 judgement_class —— 推荐等级要数的是「硬约束不符有几条」', () => {
    expect(colOf('substitution.difference_item', 'judgement_class')).toMatchObject({
      required: true,
      tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'],
    });
    /* 「判断依据」不是文件引用 —— 不写 P-10 了,名字得说实话 */
    expect(colOf('substitution.difference_item', 'evidence_note')?.required).toBe(true);
    expect(colOf('substitution.difference_item', 'evidence_ref')).toBeUndefined();
    expect(colOf('substitution.difference_item', 'unknown_disposition')?.requiredWhen).toEqual({ column: 'difference_status', value: 'unknown' });
  });

  it('P-11 主记录:退休三列,等级只剩三档', () => {
    for (const gone of ['substitution_type', 'confidence_score', 'analysis_report_ref']) {
      expect(colOf('substitution.relation', gone)).toBeUndefined();
    }
    expect(colOf('substitution.relation', 'recommendation_level')?.tally).toEqual(['recommended', 'conditional', 'not_recommended']);
  });

  it('P-06:关联的是本单案号(P-01 不在本活动里),类型加了 parametric 一档', () => {
    expect(colOf('validation.record', 'eval_ref')?.required).toBe(true);
    expect(colOf('validation.record', 'request_id')).toBeUndefined();
    expect(colOf('validation.record', 'validation_type')?.tally).toContain('parametric');
  });

  it('这个活动不碰 P-01 / P-10 / 客户档案', () => {
    for (const gone of ['demand.request', 'parts.document', 'customer.profile']) {
      expect(ledgerOf(gone)).toBeUndefined();
    }
  });
});

describe('契约', () => {
  it('启动两参,都选填 —— 缺了由第一步派工单补', () => {
    const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
    expect(Object.keys(props)).toEqual(['originalInternalPn', 'candidateInternalPn']);
    expect(manifest.contract.input.data['required']).toEqual([]);
    /* 运营端启动表单:两格都是「可选可新写」的下拉 */
    for (const key of ['originalInternalPn', 'candidateInternalPn']) {
      expect(props[key]?.['x-control']).toBe('combo');
      expect(props[key]?.['x-options']).toMatchObject({ ledger: 'parts.profile', value: 'internal_pn' });
    }
  });

  it('零工具 —— 这是这次重写的立场,不是疏漏', () => {
    expect(manifest.contract.resources.tools).toEqual([]);
    expect(manifest.declarations.tools).toEqual([]);
  });

  it('单一出口,载荷是已落地的事实', () => {
    expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['assessment-released']);
    expect(manifest.contract.output.selective).toEqual([]);
  });

  it('判据全读基座数 —— 任何一边都不许是活动自己写的汇总列', () => {
    const machine = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L1' || c.level === 'L2');
    expect(machine.length).toBeGreaterThanOrEqual(5);
    for (const c of machine) {
      expect(c.predicate).toBeDefined();
      /* 基座读数的形状:域_对象_rows / _missing / _distinct_列 / tally 计数 */
      expect(c.predicate).toMatch(/_(rows|missing|distinct_[a-z_]+|[a-z_]+)\b/);
    }
  });

  it('预算:调人三次(补数据 / 审核 / 放行),模型只出场一次', () => {
    expect(manifest.contract.resources.budget.humanCalls).toBe(8);
    expect(manifest.contract.authorization.humanCallBudget).toBe(8);
    expect(manifest.contract.resources.models).toEqual(['deepseek-v4-flash']);
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run activities/part-substitution-assessment > /tmp/red2.txt 2>&1; echo "EXIT=$?"; cat /tmp/red2.txt Expected: FAIL(声明还是旧的七张主数据 + 三张单据)。整份贴进报告。

  • Step 3: 重写 declarations.ledger

照 spec §3 逐张写。col() 帮手在文件里已有,沿用。九张(spec §3 说的「六张 + P-06」是按业务对象数,声明上是九条):

parts.chip_type · parts.type_param · parts.profile · parts.parameter · sub_eval.part · sub_eval.case · substitution.relation · substitution.difference_item · validation.record

逐张的列、档位、tally、refersTo、requiredWhen 以 spec §3.1–§3.4 为准(那几节把每一列都列全了),并把上面测试里断言到的每一条都满足。三处要写注释说清「为什么」:

        /*
         * **约束类型不在这里** —— 它长在 `parts.type_param`(按类型标一次)。
         * 从前每颗料各标一次,同一个参数在两颗料上标法不一致就成了可能;
         * 而比对是逐参数对着比的,两边约束档不同,判出来的四态就没有意义。
         */
          /*
           * **`distinct`**:「两颗料是不是同一个类型」从此是基座数得出来的数
           * (`sub_eval_part_distinct_chip_type_id`)—— 不同取值恰好 1 种才算一致。
           * 这一条是 S1 收步与 S2 入场的硬门:两边字段清单不是同一套,逐项比对无从谈起。
           */
          /*
           * **`judgement_class` 是合成列**:推荐等级要数的是「**硬约束**不符有几条」,
           * 而基座读数只会数单列 —— 把「约束类型 × 四态」先合成一列,规则才算得出来。
           * 由配方按 §3.5 的 when 链算,不是 AI 出的。
           */
  • Step 4: 重写 contract

  • input.data:两个属性 originalInternalPn / candidateInternalPn,required: [],各带 x-control: 'combo'、x-options: { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }、x-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:

pnpm exec vitest run activities/part-substitution-assessment > /tmp/green2.txt 2>&1; echo "EXIT=$?"; cat /tmp/green2.txt
pnpm gates

Expected: 新写的那两组全绿;pnpm gates 退出码 0。旧剧本引用已删台账导致的断言若被临时注掉,在报告里逐条点名。

  • Step 7: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 清单重写 —— 九张台账、两个启动参数、零工具

台账:两张类型字典 + P-08 主/参数(撤 createOnly、封装工艺归字典)+ 案卷两张 + P-11 主/明细 + P-06。
契约:启动只剩原 PN 与候选 PN 两格且都选填(缺了由第一步派工单补);tools 清空 —— 这是立场不是疏漏。
判据全读基座数;明细加合成列 judgement_class,因为「硬约束不符有几条」不是单列 tally 数得出来的。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 3: 案例模型骨架 —— 四步、四道哨兵、零 guide

Files:

  • Modify: activities/part-substitution-assessment/src/case-model.ts(整体重写)
  • Test: activities/part-substitution-assessment/__tests__/steps.test.ts(整体重写)

Interfaces:

  • Consumes: Task 2 的九张台账声明
  • Produces: CMMN_XML(四个计划项 + 三道入场哨兵)· NARRATIVE(瘦身)· ASSESSMENT_STEPS(四条剧本,guide: '',auto 先各放一笔 say)· ASSESSMENT_PHASES(三格)

这一步之后包是完整可装的:四步俱在、判据挂上、guide 全空(靠 R8 那条 schema 放宽)。 配方的实体在 Task 4–6 逐步填进去;view 先不写(它是可选的),Task 7–9 各自补上自己那一张。

  • Step 1: 写失败的测试

__tests__/steps.test.ts 整体换成:

import { describe, expect, it } from 'vitest';
import { planStepSchema } from '@contracts';
import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts';

const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
const stepOf = (item: string) => ASSESSMENT_STEPS.find((s) => s.item === item);

describe('四步案例模型', () => {
  it('恰好四步,次序即业务次序', () => {
    expect(ASSESSMENT_STEPS.map((s) => s.item)).toEqual(ITEMS);
  });

  it('每一步都过得了 PlanStep 契约', () => {
    for (const step of ASSESSMENT_STEPS) expect(planStepSchema.safeParse(step).success).toBe(true);
  });

  it('**全程零 guide、零工具** —— 这是这次重写的立场', () => {
    for (const step of ASSESSMENT_STEPS) {
      expect(step.guide).toBe('');
      expect(step.tools).toEqual([]);
      expect(step.auto).toBeDefined();
    }
  });

  it('CMMN 里四个计划项都在,且触达世界的两步标了人工激活', () => {
    for (const item of ITEMS) expect(CMMN_XML).toContain(`id="${item}"`);
    /* 审核与放行触达的是人 —— 完成与否不该由数据形状推出来 */
    for (const item of ['S3_review', 'T_release']) {
      expect(new RegExp(`id="${item}"[^>]*manualActivation="true"`).test(CMMN_XML)).toBe(true);
    }
  });

  it('**触达世界的两步不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
    expect(stepOf('S1_inputs')?.doneWhen).toBeTruthy();
    expect(stepOf('S2_assess')?.doneWhen).toBeTruthy();
    expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
    expect(stepOf('T_release')?.doneWhen).toBeUndefined();
  });

  it('**判据不许编一个数不出来的数**:doneWhen 里不出现「本类型有几个字段」这类', () => {
    /* `parts_type_param_rows` 数的是本单草稿写下的字段行(人新建的那几个),不是整本字典 —— 拿它当分母是错的 */
    for (const step of ASSESSMENT_STEPS) {
      expect(step.doneWhen ?? '').not.toContain('parts_type_param_rows');
    }
  });

  it('S1 收步:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺', () => {
    const done = stepOf('S1_inputs')?.doneWhen ?? '';
    expect(done).toContain('sub_eval_part_exists_status_found = 2');
    expect(done).toContain('sub_eval_part_missing = 0');
    expect(done).toContain('sub_eval_part_distinct_chip_type_id = 1');
    expect(done).toContain('parts_profile_missing = 0');
    expect(done).toContain('parts_parameter_missing = 0');
  });

  it('S2 收步:明细写了且不缺、关系一行、验证记录一行', () => {
    const done = stepOf('S2_assess')?.doneWhen ?? '';
    expect(done).toContain('substitution_difference_item_rows > 0');
    expect(done).toContain('substitution_difference_item_missing = 0');
    expect(done).toContain('substitution_relation_rows = 1');
    expect(done).toContain('validation_record_rows = 1');
  });

  it('两张卡各带人工验收清单 —— 一条一句人眼核得动的动作', () => {
    for (const item of ['S3_review', 'T_release']) {
      const list = stepOf(item)?.acceptanceChecklist ?? [];
      expect(list.length).toBeGreaterThanOrEqual(4);
      for (const line of list) expect(line.length).toBeGreaterThan(6);
    }
    expect(stepOf('S3_review')?.cardVariant).toBe('review');
  });

  it('阶段:除放行外每一项恰好属于一个阶段', () => {
    const covered = ASSESSMENT_PHASES.flatMap((p) => p.items);
    expect(covered.sort()).toEqual(['S1_inputs', 'S2_assess', 'S3_review'].sort());
    expect(new Set(covered).size).toBe(covered.length);
  });

  it('说明书只讲整单的事 —— 逐步做法归配方,而配方不是给模型看的', () => {
    expect(NARRATIVE.length).toBeLessThan(2000);
    /* **示例不引具体料号与参数名** —— 模型会把示例当成这一单的值(2026-08-27 复现三次) */
    for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值']) {
      expect(NARRATIVE).not.toContain(forbidden);
    }
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts > /tmp/red3.txt 2>&1; echo "EXIT=$?"; cat /tmp/red3.txt Expected: FAIL(现在是九步)。整份贴进报告。

  • Step 3: 写 CMMN
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
  <case id="part-substitution-assessment">
    <!--
      案卷项的 id 就是任务视图里的键名 —— **全是基座读数**(本单草稿写下的行数 / 缺必填数 /
      按值计数 / 不同值个数)。这一版里**一面记账旗都没有**:旗是活动自己写的数,
      而「机检只读基座数」这条纪律在本仓被违反过六次,每次都是同一种事故。
    -->
    <caseFileModel>
      <caseFileItem id="sub_eval_part_exists_status_found" name="两颗料里已在库的个数(基座现数)"/>
      <caseFileItem id="sub_eval_part_missing" name="本单两颗料缺必填的行数(基座现数)"/>
      <caseFileItem id="sub_eval_part_distinct_chip_type_id" name="两颗料用到的不同类型个数(基座现数不同值)"/>
      <caseFileItem id="parts_profile_missing" name="本单碰过的芯片主数据缺必填的行数(基座现数)"/>
      <caseFileItem id="parts_parameter_missing" name="本单碰过的参数行缺必填的行数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_rows" name="差异明细行数(基座现数)"/>
      <caseFileItem id="substitution_difference_item_missing" name="差异明细缺必填的行数(基座现数)"/>
      <caseFileItem id="substitution_relation_rows" name="替代关系行数(基座现数)"/>
      <caseFileItem id="substitution_relation_missing" name="替代关系缺必填的行数(基座现数)"/>
      <caseFileItem id="validation_record_rows" name="验证记录行数(基座现数)"/>
      <caseFileItem id="validation_record_missing" name="验证记录缺必填的行数(基座现数)"/>
    </caseFileModel>
    <casePlanModel id="plan" name="差异评估" autoComplete="true">
      <!-- ① 入参齐备:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺 —— 分析那一步的入场前提 -->
      <sentry id="s_inputs_ready">
        <onPart id="op_i" caseFileItemRef="sub_eval_part_exists_status_found"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_i"><condition>sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and sub_eval_part_distinct_chip_type_id = 1 and parts_profile_missing = 0 and parts_parameter_missing = 0</condition></ifPart>
      </sentry>
      <!-- ② 结论已成形:逐条明细、一条关系、一条验证记录都写下了且不缺 —— 审核那一步的入场前提 -->
      <sentry id="s_assessed">
        <onPart id="op_a" caseFileItemRef="substitution_difference_item_rows"><standardEvent>update</standardEvent></onPart>
        <ifPart id="if_a"><condition>substitution_difference_item_rows &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)
export const ASSESSMENT_STEPS = [
  {
    item: 'S1_inputs',
    intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补',
    guide: '',
    writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'],
    reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'],
    tools: [],
    doneWhen:
      'sub_eval_part_exists_status_found = 2 and sub_eval_part_missing = 0 and ' +
      'sub_eval_part_distinct_chip_type_id = 1 and parts_profile_missing = 0 and parts_parameter_missing = 0',
    auto: { do: [{ kind: 'say', text: '正在核对两颗料的在库状态与参数齐备情况。' }] },
  },
  {
    item: 'S2_assess',
    intent: '把两颗料的核心参数逐条交给模型判四态,系统按规则算推荐等级、风险与验证要求',
    guide: '',
    writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'],
    reads: ['parts.type_param', 'parts.parameter', 'sub_eval.part', 'sub_eval.case'],
    tools: [],
    doneWhen:
      'substitution_difference_item_rows > 0 and substitution_difference_item_missing = 0 and ' +
      'substitution_relation_rows = 1 and validation_record_rows = 1',
    auto: { do: [{ kind: 'say', text: '正在逐条比对两颗料的核心参数。' }] },
  },
  {
    item: 'S3_review',
    intent: '把逐条结论摆给人过目,他就地改四态与影响说明,改到没问题为止',
    guide: '',
    writes: ['substitution.difference_item', 'substitution.relation', 'validation.record'],
    reads: ['substitution.difference_item', 'substitution.relation'],
    tools: [],
    cardVariant: 'review',
    /* 人工验收清单:一条一句他拿着这份工单要**亲自核**的动作,不是系统判的判据 */
    acceptanceChecklist: [
      '逐条对过原料与候选料的参数值,没有把需求边界写成候选料的问题',
      '硬约束那几条逐条看过,没有一条是「没证据也判了 MATCH」',
      '判 UNKNOWN 的都写了去向:实测哪一项、回填到哪儿',
      '影响说明站得住:说的是「换上去会不会比现在差」,不是复述参数值',
    ],
    auto: { do: [{ kind: 'say', text: '差异结论已备好,请过目。' }] },
  },
  {
    item: 'T_release',
    intent: '把这一签下去会落哪几张表、几行摆清楚,人签字后落账并发口',
    guide: '',
    writes: ['sub_eval.case'],
    reads: ['substitution.relation', 'substitution.difference_item', 'validation.record', 'sub_eval.case'],
    tools: [],
    acceptanceChecklist: [
      '推荐等级与四态分布对得上:有硬约束不符的没给「推荐」',
      '待验项清单认可:该实测的都列出来了,没有该验而漏掉的',
      '落账内容核对过:进哪几张表、各几行,与屏上摆的一致',
      '参数值来源可追:每一条都能说出它是库里既有的还是这一单补录的',
      '签下去就发给下游,这一步之后本活动不再改动这些行',
    ],
    auto: { do: [{ kind: 'say', text: '拟落账内容已备好,请核对后签字。' }] },
  },
] as const;

export const ASSESSMENT_PHASES = [
  { name: '校验入参', items: ['S1_inputs'] },
  { name: '差异分析', items: ['S2_assess'] },
  { name: '结论审核', items: ['S3_review'] },
] as const;

阶段里没有 T_release —— 放行那一格由基座统一加(与 E04 / I01 同款)。 用例钉的正是「除放行外每一项恰好属于一个阶段」:漏一格,人手上办的那件事在进度条上就没有位置。

  • Step 6: 跑测试与全量门禁

Run:

pnpm exec vitest run activities/part-substitution-assessment > /tmp/green3.txt 2>&1; echo "EXIT=$?"; cat /tmp/green3.txt
pnpm gates

Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。

  • Step 7: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 案例模型换成四步 —— 零 guide、零工具、零记账旗

九步砍成四步:校验入参 / 差异分析 / 结论审核 / 落账放行。
三道入场哨兵全读基座数,caseFileModel 里一面记账旗都没有 ——
旗是活动自己写的数,而「机检只读基座数」这条在本仓被违反过六次。
审核与放行触达的是人:标人工激活、不给 doneWhen。
说明书瘦成「整单的事」,示例一律占位形 —— 模型会把示例当成这一单的值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 4: S1 配方 —— 校验入参与补数据

Files:

  • Modify: activities/part-substitution-assessment/src/case-model.ts(S1_inputs.auto)
  • Modify: activities/part-substitution-assessment/src/manifest.ts(sub_eval.part 加一列 found_pn_ref)
  • Test: activities/part-substitution-assessment/__tests__/steps.test.ts

Interfaces:

  • Consumes: Task 3 的剧本骨架;AutoRecipe 契约(五动词 / 八式表达式 / 四种地址)
  • Produces: S1 的六笔配方;sub_eval.part.found_pn_ref(在库核对依据,refersTo parts.profile.internal_pn)

动手前先读《活动开发规范》§4.6 —— 它是配方怎么写的权威,含四条纪律。

  • Step 1: 确认 sub_eval.part 上有 found_pn_ref(Task 2 照 spec §3.3 应当已经写了)

Run: grep -n "found_pn_ref" activities/part-substitution-assessment/src/manifest.ts 有就跳过这一步。 没有(Task 2 漏了)就在 role 之后补上 —— exists_status 不能凭空判, 判「在库」就必须拿得出一个基座查得到的料号:

          /*
           * **在库核对依据**:判 `found` 就得把在主数据里真找到的那个料号填在这里,
           * 基座拿它去 `parts.profile` 查(`refersTo`),查无此值当场拒。
           * 这一列的存在是为了让「在不在库」成为一次 `lookup` 的结果,而不是一句断言 ——
           * 2026-08-27 实测过:原 PN 根本不在库,而模型把两颗都写成 found、交代里还说「都在库」。
           */
          col({
            name: 'found_pn_ref',
            type: 'text',
            label: '在库核对依据',
            refersTo: 'parts.profile.internal_pn',
            requiredWhen: { column: 'exists_status', value: 'found' },
          }),

并确认 Task 2 那条「案卷两张」用例里有这一行断言(没有就补):

    expect(colOf('sub_eval.part', 'found_pn_ref')?.refersTo).toBe('parts.profile.internal_pn');
  • Step 2: 写失败的测试

追加到 steps.test.ts:

describe('S1 配方', () => {
  const recipe = stepOf('S1_inputs')?.auto;
  const acts = recipe?.do ?? [];
  const kinds = acts.map((a) => a.kind);

  it('六笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => {
    expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']);
    /* 案卷排在骨架两笔之后 —— gate_reason 引的 _missing 要等骨架铺出来才有值,提前算它那句话是空的 */
    expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
      'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
    ]);
  });

  it('案号由 let 铸,只从本单开案时刻取材', () => {
    expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
  });

  it('两颗料由字面两行立起来 —— 这张表是后面每一笔遍历的唯一来源', () => {
    const part = acts[0];
    expect(part?.kind === 'project' && 'rows' in part.from && part.from.rows).toHaveLength(2);
    /* 在库与否是一次 lookup 的结果,不是一句断言:先取依据,再按依据判 */
    const fields = part?.kind === 'project' ? part.fields : {};
    expect(fields['found_pn_ref']).toMatchObject({ lookup: 'parts.profile', take: 'internal_pn' });
    expect(fields['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' });
  });

  it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
    const params = acts[2];
    expect(params?.kind === 'project' && params.prune).toBe(true);
    const from = params?.kind === 'project' ? params.from : undefined;
    /* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
    expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } });
    const fields = params?.kind === 'project' ? params.fields : {};
    expect(fields['param_value']).toMatchObject({ lookup: 'parts.parameter', take: 'param_value' });
  });

  it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
    const card = acts[4];
    const when = card?.kind === 'card' ? (card.when ?? '') : '';
    /*
     * S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
     * 于是未绑定 —— 拿它当条件,恰恰在一切正常时把卡发出去。条件只用恒有值的基座读数。
     */
    expect(when).not.toContain('gate_reason');
    expect(when).toContain('sub_eval_part_exists_status_found < 2');
    expect(when).toContain('parts_parameter_missing > 0');
  });

  it('卡:数据工单、四张表都在闸上、且有 basis(基座拒发 basis 为空的工单)', () => {
    const card = acts[4];
    expect(card?.kind === 'card' && card.as).toBe('execution');
    expect(card?.kind === 'card' && card.variant).toBe('data');
    expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']);
    expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
    expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
  });

  it('发卡条件与 doneWhen 是同一件事的正反面', () => {
    const when = acts[4]?.kind === 'card' ? (acts[4].when ?? '') : '';
    for (const reading of [
      'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
      'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
    ]) {
      expect(when).toContain(reading);
      expect(stepOf('S1_inputs')?.doneWhen).toContain(reading);
    }
  });
});
  • Step 3: 跑一遍,确认它失败

Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S1 配方" > /tmp/red4.txt 2>&1; echo "EXIT=$?"; cat /tmp/red4.txt Expected: FAIL(现在只有一笔 say)。整份贴进报告。

  • Step 4: 写配方

把 S1_inputs.auto 换成:

    auto: {
      /** 整单只有这一个案号,从本单开案时刻取材 —— **不许从正式表里别单的号里挑**(踩过) */
      let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] } },
      do: [
        /*
         * ① 本单两颗料 —— **后面每一笔遍历的唯一来源**。
         * 挤进案卷一行里就没法 `where` 过滤,而不能过滤就会给已经在库的那颗也铺一遍空参数行。
         */
        {
          kind: 'project',
          into: 'sub_eval.part',
          from: {
            rows: [
              { pn: { in: 'originalInternalPn' }, role: 'original' },
              { pn: { in: 'candidateInternalPn' }, role: 'candidate' },
            ],
          },
          fields: {
            eval_ref: { in: 'evalRef' },
            internal_pn: { col: 'pn' },
            role: { col: 'role' },
            /* 先取依据,再按依据判 —— 字段按声明顺序求值,算出来的进同一行的 S-FEEL 作用域 */
            found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' },
            exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' },
            chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' },
          },
        },
        /*
         * ② 主数据骨架:**两颗料都铺、值回填现值**。
         * 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。
         * **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。
         * 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。
         */
        {
          kind: 'project',
          into: 'parts.profile',
          prune: true,
          from: { each: 'mine:sub_eval.part' },
          fields: {
            internal_pn: { col: 'internal_pn' },
            chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' },
            brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' },
            lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' },
            specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' },
            manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' },
            datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' },
            datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' },
          },
        },
        /*
         * ③ 参数骨架 —— 三件事一笔办完:
         *   · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**;
         *     他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**;
         *   · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报;
         *   · **「在库但当年参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。
         */
        {
          kind: 'project',
          into: 'parts.parameter',
          prune: true,
          from: {
            each: 'mine:sub_eval.part',
            then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
          },
          fields: {
            internal_pn: { outer: 'internal_pn' },
            param_name: { col: 'param_name' },
            param_unit: { col: 'param_unit' },
            param_value: {
              lookup: 'parts.parameter',
              by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } },
              take: 'param_value',
              else: '',
            },
            param_note: {
              lookup: 'parts.parameter',
              by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } },
              take: 'param_note',
              else: '',
            },
          },
        },
        /*
         * ④ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤)。
         * **这一笔必须排在②③之后,不许提前**(2026-08-29 走查实测,评审用活探针复现):
         * 它引的 `parts_profile_missing` / `parts_parameter_missing` 要等②③把骨架行铺出来才有值。
         * 排在前面时,引擎在②③写入**之前**给它读到 0 —— 卡照样正确发出(⑤ 的 `when` 排在更后面,
         * 读到的是刷新后的真缺口),但卡上那句「还差什么」是**空白的**,恰在人最想要一句解释的时候。
         * **一笔读的是另一笔写出来的数,它就得排在那一笔后面。**
         */
        {
          kind: 'project',
          into: 'sub_eval.case',
          from: { once: true },
          fields: {
            eval_ref: { in: 'evalRef' },
            chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' },
            gate_reason: {
              when: 'sub_eval_part_rows < 2',
              then: '原料号与候选料号还没填全 —— 在下面把两格补上。',
              else: {
                when: 'sub_eval_part_exists_status_absent > 0',
                then: '有料号库里查不到 —— 在下面把它的主数据建起来。',
                else: {
                  when: 'sub_eval_part_missing > 0',
                  then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。',
                  else: {
                    when: 'sub_eval_part_distinct_chip_type_id > 1',
                    then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。',
                    else: {
                      when: 'parts_profile_missing + parts_parameter_missing > 0',
                      then: '还有必填格空着 —— 红带点名的那几格填上就能提交。',
                      else: '',
                    },
                  },
                },
              },
            },
          },
        },
        /*
         * ⑤ 缺什么就派一张数据工单。
         * **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定,
         * 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。
         */
        {
          kind: 'card',
          when:
            'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or ' +
            'sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0',
          as: 'execution',
          variant: 'data',
          title: '数据工单 · 差异评估的输入还差几格',
          basis: [
            { k: '原料号', v: { in: 'originalInternalPn' } },
            { k: '候选料号', v: { in: 'candidateInternalPn' } },
            { k: '还差什么', v: { in: 'gate_reason' } },
          ],
          guide: [
            '两颗料必须归到**同一个芯片类型**下 —— 要比的核心参数字段由类型定,类型不同就没有可比的字段。',
            '类型下拉里没有合适的,就新建一个,并把它的**核心参数字段**一并定下来:每个字段要有名字、一句说明、以及硬约束还是软约束。',
            '说明写「这个参数为什么要紧」——**它是后面判定差异时唯一的解释来源**,写成参数名的复述等于没写。',
            '硬约束 = 不满足就不能用(烧芯片、超温域、装不上);软约束 = 影响表现但可权衡(功耗、噪声)。',
            '参数值填**原厂规格书上的值**,查不到就留空 —— 留空会被红带点名,但**编一个值比留空坏得多**。',
          ],
          standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。',
          gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'],
        },
        { kind: 'say', text: '已核对两颗料的在库状态与参数齐备情况。' },
      ],
    },
  • Step 5: 跑测试与全量门禁

Run:

pnpm exec vitest run activities/part-substitution-assessment > /tmp/green4.txt 2>&1; echo "EXIT=$?"; cat /tmp/green4.txt
pnpm gates

Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。

  • Step 6: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): S1 配方 —— 校验入参、铺格、缺什么派一张数据工单

在库与否是一次 lookup 的结果,不是一句断言:先取 found_pn_ref(refersTo 查无此值当场拒)再按它判。
参数骨架两颗料都铺、值回填现值 —— 于是「在库但当年参数没录全」也自动被红带拦住。
发卡的条件只用基座读数:gate_reason 为空串时那一列不写、未绑定,而 S-FEEL 里未绑定的名字
`!= ""` 求值为 true —— 拿它当条件恰恰会在一切正常时把卡发出去。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 5: S2 配方 —— AI 唯一一次出场,以及那套规则

Files:

  • Modify: activities/part-substitution-assessment/src/case-model.ts(S2_assess.auto)
  • Test: activities/part-substitution-assessment/__tests__/steps.test.ts

Interfaces:

  • Consumes: S1 落下的 sub_eval.part / sub_eval.case / parts.parameter;字典 parts.type_param
  • Produces: S2 的五笔配方 —— analyze(整个活动唯一一次模型调用)+ 三笔 project + 一笔 say

这一步是整套设计的要害。 三处刻意的克制,评审会逐条核: ① 行由字典决定,不由 AI 决定 —— 明细按 parts.type_param each 出来,AI 的出参只是拿 param_name 去 lookup 的一张查表;它漏一条,那一格就是空,required 立刻变成 _missing > 0,闸与放行门当场拦住。 写成 from each(AI 出参) 的话,它少给两条就少两行,而每一行看着都合规。 ② 值是多少一个字都不重述 —— 基座读得到的事实不让模型转述(本仓踩过:模型念出的「输入上限 32V」 是示例里的数,不是这一单的)。 ③ 推荐等级、风险、摘要、推荐理由全由规则与模板拼 —— 于是永远不会出现「有硬约束不符却写着推荐」。

  • Step 1: 写失败的测试

追加到 steps.test.ts:

describe('S2 配方 —— AI 只出场一次', () => {
  const recipe = stepOf('S2_assess')?.auto;
  const acts = recipe?.do ?? [];

  it('五笔:分析 → 明细 → 关系 → 验证记录 → 交代;analyze 恰好一笔', () => {
    expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'say']);
    expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
  });

  it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
    const items = acts[1];
    expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
    const fields = items?.kind === 'project' ? items.fields : {};
    /* AI 的出参只是一张查表:漏一条,那一格就是空 —— required 立刻把它变成 _missing > 0 */
    expect(fields['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' });
    expect(fields['impact_statement']).toMatchObject({ lookup: 'result:items' });
    expect(fields['evidence_note']).toMatchObject({ lookup: 'result:items' });
  });

  it('**值不取 AI 出参,一律 lookup 台账**', () => {
    const fields = acts[1]?.kind === 'project' ? acts[1].fields : {};
    for (const col of ['original_value', 'candidate_value']) {
      expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' });
    }
    /* 约束类型来自字典这一行本身,不是 AI 说的 */
    expect(fields['constraint_type']).toEqual({ col: 'constraint_type' });
  });

  it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
    const fields = acts[1]?.kind === 'project' ? acts[1].fields : {};
    const jc = JSON.stringify(fields['judgement_class']);
    expect(jc).toContain('hard_mismatch');
    expect(jc).toContain('soft_mismatch');
    /* 顺序要紧:先认四态,最后才按约束类型分硬软 */
    expect(jc.indexOf('"match"')).toBeLessThan(jc.indexOf('hard_mismatch'));
  });

  it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => {
    const rel = acts[2]?.kind === 'project' ? acts[2].fields : {};
    const level = JSON.stringify(rel['recommendation_level']);
    expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
    expect(level).toContain('not_recommended');
    /* 硬约束不符必须直接落到「不推荐」,排在最前 */
    expect(level.indexOf('hard_mismatch')).toBeLessThan(level.indexOf('unknown'));
  });

  it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => {
    const val = acts[3];
    expect(val?.kind === 'project' && val.into).toBe('validation.record');
    expect(val?.kind === 'project' && val.from).toEqual({ once: true });
    const fields = val?.kind === 'project' ? val.fields : {};
    expect(fields['validation_status']).toBe('not_started');
    expect(fields['validation_type']).toBe('parametric');
  });

  it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => {
    const analyze = acts[0];
    const prompt = analyze?.kind === 'analyze' ? analyze.instruction : '';
    expect(prompt).toContain('不要复述');
    expect(prompt).toContain('unknown');
    expect(prompt).toContain('硬约束');
    for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值', '输入电压范围']) {
      expect(prompt).not.toContain(forbidden);
    }
    /* 出参 schema 顶层是对象,数组字段名与 into 对得上 */
    const out = analyze?.kind === 'analyze' ? analyze.output : {};
    expect(out['type']).toBe('object');
    expect(analyze?.kind === 'analyze' && analyze.into).toBe('items');
  });

  it('AI 的入参**每一条都带说明** —— 参数名本身说不清「它为什么要紧」', () => {
    const analyze = acts[0];
    const fields = analyze?.kind === 'analyze' ? analyze.input.fields : {};
    expect(fields['param_desc']).toEqual({ col: 'param_desc' });
    expect(fields['constraint_type']).toEqual({ col: 'constraint_type' });
    for (const col of ['original_value', 'candidate_value']) {
      expect(fields[col]).toMatchObject({ lookup: 'parts.parameter' });
    }
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S2 配方" > /tmp/red5.txt 2>&1; echo "EXIT=$?"; cat /tmp/red5.txt Expected: FAIL。整份贴进报告。

  • Step 3: 写配方

把 S2_assess.auto 换成下面这份。let 里为什么不用 {in:'chip_type_id'}:chip_type_id 这个名字在 parts.profile / sub_eval.part / sub_eval.case 三张表上都有,而基座把「各对象最新一行」的字段名铺到顶层、 同名以后写的赢 —— 拿顶层那个名字取值等于赌哪张表最后写。改成对准一张表 lookup,不歧义。

    auto: {
      let: {
        evalRef: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
        typeId: { lookup: 'mine:sub_eval.case', by: {}, take: 'chip_type_id', else: '' },
        originalPn: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' },
        candidatePn: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' },
        relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] },
      },
      do: [
        /*
         * ① **整个活动唯一一次模型调用**。系统把两颗料在该类型下的每一条核心参数
         * 组装好交给它,它只答「这一条算什么、为什么」——**不给工具、不给记忆、不多轮**。
         */
        {
          kind: 'analyze',
          model: 'deepseek-v4-flash',
          into: 'items',
          instruction: [
            '你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
            '硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
            '',
            '对**每一条**给出四态之一:',
            '· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
            '· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
            '· unknown —— 任一侧的值缺失或语义不明,判不了;',
            '· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
            '',
            '三条硬规矩:',
            '1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
            '2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
            '3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
            '',
            '范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
            '单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
            '',
            '只输出 JSON,不要解释、不要 markdown 代码围栏。',
          ].join('\n'),
          input: {
            from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
            fields: {
              param_name: { col: 'param_name' },
              /* **AI 分析时读的就是它** —— 参数名本身说不清「它为什么要紧」 */
              param_desc: { col: 'param_desc' },
              constraint_type: { col: 'constraint_type' },
              unit: { col: 'param_unit' },
              original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
              original_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
              candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
              candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
            },
          },
          output: {
            type: 'object',
            required: ['items'],
            properties: {
              items: {
                type: 'array',
                items: {
                  type: 'object',
                  required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
                  properties: {
                    param_name: { type: 'string' },
                    difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
                    impact_statement: { type: 'string' },
                    evidence_note: { type: 'string' },
                    unknown_disposition: { type: 'string' },
                  },
                },
              },
            },
          },
        },
        /*
         * ② 逐条明细 —— **行由字典 `each` 出来**,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表。
         * 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
         * 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
         */
        {
          kind: 'project',
          into: 'substitution.difference_item',
          prune: true,
          from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
          fields: {
            substitution_relation_id: { in: 'relationId' },
            difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
            parameter_name: { col: 'param_name' },
            parameter_desc: { col: 'param_desc' },
            parameter_unit: { col: 'param_unit' },
            /* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
            constraint_type: { col: 'constraint_type' },
            original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
            candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
            difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
            impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
            evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
            unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
            /* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 */
            judgement_class: {
              when: 'difference_status = "match"', then: 'match',
              else: {
                when: 'difference_status = "unknown"', then: 'unknown',
                else: {
                  when: 'difference_status = "conditional"', then: 'conditional',
                  else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' },
                },
              },
            },
            risk_level: {
              when: 'judgement_class = "hard_mismatch"', then: 'high',
              else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' },
            },
            rule_version: RULE_VERSION,
          },
        },
        /*
         * ③ 关系一行 —— **等级、风险、摘要、理由全由规则与模板拼,不让 AI 编**。
         * 于是永远不会出现「有硬约束不符却写着推荐」;人在审核件上改了某一条四态,这一笔重跑,等级跟着变。
         */
        {
          kind: 'project',
          into: 'substitution.relation',
          from: { once: true },
          fields: {
            substitution_relation_id: { in: 'relationId' },
            eval_ref: { in: 'evalRef' },
            original_internal_pn: { in: 'originalPn' },
            candidate_internal_pn: { in: 'candidatePn' },
            chip_type_id: { in: 'typeId' },
            recommendation_level: {
              when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended',
              else: {
                when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional',
                else: {
                  when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional',
                  else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' },
                },
              },
            },
            risk_level: {
              when: 'recommendation_level = "not_recommended"', then: 'high',
              else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' },
            },
            difference_summary: {
              cat: [
                '共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:',
                { in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ',
                { in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ',
                { in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ',
                { in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ',
                { in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。',
              ],
            },
            recommendation_reason: {
              when: 'recommendation_level = "not_recommended"',
              then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。',
              else: {
                when: 'recommendation_level = "recommended"',
                then: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
                else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。',
              },
            },
            verification_requirement: {
              when: 'recommendation_level = "conditional"',
              /* `{joinRows}` 未实现 —— 不逐个列参数名,指向明细表(那里逐条摆着,且人能就地改) */
              then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。',
              else: '',
            },
            validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
            analysis_status: 'fae_confirmed',
            rule_version: RULE_VERSION,
          },
        },
        /*
         * ④ 验证记录一行 —— **一单固定一条**(用户定)。本活动只写「还得去验什么」,
         * 状态一律 `not_started`:它不是实测结果,**不许虚构实测**。
         */
        {
          kind: 'project',
          into: 'validation.record',
          from: { once: true },
          fields: {
            validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
            eval_ref: { in: 'evalRef' },
            validation_internal_pn: { in: 'candidatePn' },
            validation_type: 'parametric',
            validation_status: 'not_started',
            test_condition: {
              when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
              then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。',
              else: '无',
            },
            next_validation_action: {
              when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
              then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。',
              else: '本轮无待验项。',
            },
          },
        },
        { kind: 'say', text: '逐条差异结论已备好,推荐等级由规则按四态算出。' },
      ],
    },

RULE_VERSION 从 constants.ts 引(它已存在;若被 Task 2 删掉了就补回来)。

  • Step 4: 跑测试与全量门禁

Run:

pnpm exec vitest run activities/part-substitution-assessment > /tmp/green5.txt 2>&1; echo "EXIT=$?"; cat /tmp/green5.txt
pnpm gates

Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。

  • Step 5: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): S2 配方 —— AI 唯一一次出场,行由字典决定不由它决定

明细按类型字典 each 出来,AI 的出参只是拿参数名去 lookup 的一张查表:
它漏一条,那一行的四态就是空,required 立刻变成 _missing > 0,闸与放行门当场拦住。
从前是 from each(AI 出参) —— 少给两条就少两行,而每一行看着都合规。
值一律 lookup 台账,一个字不让它重述;推荐等级、风险、摘要、理由全由规则与模板拼。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 6: S3 与 S4 配方 —— 审核卡与落账放行

Files:

  • Modify: activities/part-substitution-assessment/src/case-model.ts(抽出两份共享投影;S3_review.auto / T_release.auto)
  • Test: activities/part-substitution-assessment/__tests__/steps.test.ts

Interfaces:

  • Consumes: Task 5 的关系与验证记录投影
  • Produces: RELATION_PROJECTION / VALIDATION_PROJECTION 两个模块级常量(S2 与 S3 共用);S3 的四笔;S4 的两笔

为什么 S3 要重跑那两笔投影:人在审核件上改了某一条四态,推荐等级、风险、摘要、验证要求都得跟着变。 「让人直接改摘要」等于让他给自己没做的判断盖章 —— 所以摘要只读,改的是四态,系统重算。 投影必须是同一份,各写一份早晚分叉:抽成模块级常量,两处引用。

次序:投影在前、卡在后。 人还在填(quiet)时卡被跳过、投影照跑;人交回后(carded)卡这一笔 只跳过自己、投影仍跑 —— 「人的答案已经在草稿里」的那一刻,重算才发生。

  • Step 1: 写失败的测试

追加到 steps.test.ts:

describe('S3 / S4 配方', () => {
  const s3 = stepOf('S3_review')?.auto?.do ?? [];
  const s4 = stepOf('T_release')?.auto?.do ?? [];
  const s2 = stepOf('S2_assess')?.auto?.do ?? [];

  it('S3 四笔:两笔重算在前、卡在后 —— 人答完那一刻重算才发生', () => {
    expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'card', 'say']);
    expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.relation');
    expect(s3[1]?.kind === 'project' && s3[1].into).toBe('validation.record');
  });

  it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
    expect(s3[0]).toBe(s2[2]);
    expect(s3[1]).toBe(s2[3]);
  });

  it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
    const card = s3[2];
    expect(card?.kind === 'card' && card.as).toBe('decide');
    expect(card?.kind === 'card' && card.variant).toBe('review');
    expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
    expect((card?.kind === 'card' ? card.options ?? [] : [])[0]?.value).toBe('approve');
    expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
  });

  it('S4 两笔:写结论一句话 → 提交放行', () => {
    expect(s4.map((a) => a.kind)).toEqual(['project', 'submit']);
    expect(s4[0]?.kind === 'project' && s4[0].into).toBe('sub_eval.case');
    expect(s4[1]?.kind === 'submit' && typeof s4[1].rationale !== 'undefined').toBe(true);
  });

  it('`submit` 所在的步声明了人工验收清单 —— 系统提交不比模型提交宽松', () => {
    expect((stepOf('T_release')?.acceptanceChecklist ?? []).length).toBeGreaterThanOrEqual(4);
  });

  it('两步都不给 doneWhen —— 收步由人答卡决定', () => {
    expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
    expect(stepOf('T_release')?.doneWhen).toBeUndefined();
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/steps.test.ts -t "S3 / S4 配方" > /tmp/red6.txt 2>&1; echo "EXIT=$?"; cat /tmp/red6.txt Expected: FAIL。整份贴进报告。

  • Step 3: 把两笔投影抽成模块级常量

在 ASSESSMENT_STEPS 之前加(内容原样取自 Task 5 写的 S2 第③④笔),然后 S2 与 S3 都引用它们:

这是一次移动,不是重写:上一个任务已经把这两个对象字面量写在 case-model.ts 里了。

打开 case-model.ts,找到 S2_assess.auto.do 数组里的第 3 笔(into: 'substitution.relation' 那个对象字面量) 与第 4 笔(into: 'validation.record' 那个),整体剪切到 ASSESSMENT_STEPS 之前,各起一个名字:

/*
 * **关系与验证记录的投影,S2 与 S3 共用同一份**。
 * S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 ——
 * 各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的等级。
 */
const RELATION_PROJECTION = { /* ← 从 S2 第 3 笔整体剪切过来,一个字不改 */ } as const;
const VALIDATION_PROJECTION = { /* ← 从 S2 第 4 笔整体剪切过来,一个字不改 */ } as const;

再把 S2 原来那两个位置换成对常量的引用。剪切前后 pnpm exec vitest run activities/part-substitution-assessment 必须仍然全绿 —— Task 5 写的那几条用例断言的就是这两笔的内容,它们此刻正是这次移动的安全网。 用例里 expect(s3[0]).toBe(s2[2]) 用的是 toBe(同一个对象引用),照抄一份不算数。

  • Step 4: 写 S3 与 S4 的配方
    /* S3_review.auto */
    auto: {
      do: [
        /* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */
        RELATION_PROJECTION,
        VALIDATION_PROJECTION,
        {
          kind: 'card',
          as: 'decide',
          variant: 'review',
          title: '差异评估结论审核',
          ask: '逐条差异结论已备好。请在工作区的件上逐条核对;要改的就地改(四态、影响说明、判断依据都可改),改完回到这里确认。',
          options: [{ value: 'approve', label: '确认通过', detail: '逐条核对无误,可以进入落账放行' }],
          basis: [
            { k: '原料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' } },
            { k: '候选料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' } },
            { k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
          ],
        },
        { kind: 'say', text: '差异结论已送审,请在件上逐条核对。' },
      ],
    },
    /* T_release.auto */
    auto: {
      do: [
        {
          kind: 'project',
          into: 'sub_eval.case',
          from: { once: true },
          fields: {
            eval_ref: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
            summary: {
              cat: [
                { lookup: 'mine:substitution.relation', by: {}, take: 'candidate_internal_pn', else: '' },
                ' 替代 ',
                { lookup: 'mine:substitution.relation', by: {}, take: 'original_internal_pn', else: '' },
                ':',
                { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' },
              ],
            },
          },
        },
        {
          kind: 'submit',
          rationale: {
            cat: [
              '逐项差异评估已经审核通过:',
              { in: 'substitution_difference_item_rows' }, ' 条明细、1 条替代关系、1 条验证记录待落账。',
            ],
          },
        },
      ],
    },
  • Step 5: 跑测试与全量门禁

Run:

pnpm exec vitest run activities/part-substitution-assessment > /tmp/green6.txt 2>&1; echo "EXIT=$?"; cat /tmp/green6.txt
pnpm gates

Expected: 全绿(整份贴进报告);pnpm gates 退出码 0。

  • Step 6: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): S3 审核卡与 S4 落账放行 —— 摘要只读,改的是四态

关系与验证记录的投影抽成模块级常量,S2 与 S3 共用同一份:
人改了某一条四态,等级 / 风险 / 摘要 / 验证要求跟着重算。
各写一份早晚分叉,而分叉那一刻屏上会同时摆着两个互相矛盾的等级。
次序是投影在前、卡在后:人交回后卡这一笔只跳过自己,投影仍跑 —— 重算才发生在该发生的那一刻。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 7: 件① input-supplement —— 评估输入补全(数据工单)

Files:

  • Modify: activities/part-substitution-assessment/src/views/report.ts(新增 INPUT_SUPPLEMENT,删旧的五张)
  • Modify: activities/part-substitution-assessment/src/views/target-style.ts(留 P08_CSS,删无主的)
  • Modify: activities/part-substitution-assessment/src/manifest.ts(viewTemplates 加它)+ case-model.ts(S1_inputs.view)
  • Test: activities/part-substitution-assessment/__tests__/activity.test.ts

Interfaces:

  • Consumes: Task 4 的 S1 配方(它铺的行就是这张件上要填的行)
  • Produces: 视图模版 input-supplement

这张件三块竖排(spec §6.1),绑定契约如下 —— 模版只取值、不判断:

块 绑定 说明
① 要评估什么 <tbody data-rows="mine:sub_eval.part" data-edit="parts.profile"> 两行(行由系统铺,人不加行)。每行摆 internal_pn(主键,必挂 data-col —— 它不是编辑面,是行的钥匙)、在库状态徽(data-class-of="exists_status")、以及可改的主数据格
② 芯片类型 <select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"> 选项从台账现读;下面 <tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"> 供新建类型时加字段行
③ 参数值 <tbody data-rows="mine:parts.parameter" data-edit="parts.parameter"> 一字段一行、两栏都可填;param_name 挂 data-col 当钥匙,param_value / param_note 可改
顶部红带 <div data-class-of="gate_reason"> + <span data-slot="gate_reason"> 「还差什么」由 S1 的配方算好摆上来;一格不缺时那条带子由样式表隐掉(v- 空值那一档 display:none),模版不判断
  • Step 1: 写失败的测试

追加到 activity.test.ts:

const viewOf = (name: string) => manifest.assets.viewTemplates.find((v) => v.name === name)?.html ?? '';

describe('件① 评估输入补全', () => {
  const html = viewOf('input-supplement');

  it('在 viewTemplates 里,且 S1 指向它', () => {
    expect(html).not.toBe('');
    expect(ASSESSMENT_STEPS.find((s) => s.item === 'S1_inputs')?.view).toBe('input-supplement');
  });

  it('三块都在,行都绑本单草稿(不是整表)', () => {
    expect(html).toContain('data-rows="mine:sub_eval.part"');
    expect(html).toContain('data-rows="mine:parts.parameter"');
    expect(html).toContain('data-rows="mine:parts.type_param"');
  });

  it('芯片类型是从台账现读的下拉 —— 写死等于「只能选出包那天就有的那几种」', () => {
    expect(html).toContain('data-options="parts.chip_type"');
    expect(html).toContain('data-option-value="type_id"');
    expect(html).toContain('data-option-label="type_name"');
    /* data-options 与 data-slot 不能共存(渲染时 data-slot 会被剥掉)—— 当下值由 data-col 定 */
    expect(/data-options="[^"]*"[^>]*data-slot=/.test(html)).toBe(false);
  });

  it('**主键列必挂 `data-col`** —— 它不是编辑面,是行的钥匙(漏了会把人改的行错配到别行上)', () => {
    expect(html).toContain('data-col="internal_pn"');
    expect(html).toContain('data-col="param_name"');
  });

  it('可改的三张表各有一道编辑闸', () => {
    for (const address of ['parts.profile', 'parts.type_param', 'parts.parameter']) {
      expect(html).toContain(`data-edit="${address}"`);
    }
  });

  it('模版不算不判:没有内联脚本,红带的显隐交给样式表', () => {
    expect(html).not.toContain('<script');
    expect(html).toContain('data-class-of="gate_reason"');
  });

  it('表宽超出要能横着滑 —— 不许写成 overflow:hidden(E04/I01 抄错过一次)', () => {
    expect(html).not.toMatch(/\.jc-tbl-wrap\s*\{[^}]*overflow\s*:\s*hidden/);
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件①" > /tmp/red7.txt 2>&1; echo "EXIT=$?"; cat /tmp/red7.txt Expected: FAIL。整份贴进报告。

  • Step 3: 写模版

用 report.ts 里既有的 doc({ css, alias, badge, hic?, tag, kicker, title, step, body, footTag }) 帮手(签名照文件里的), css 用 P08_CSS(数据工单那一套版面语言,已是从目标件抽出来的)。body 按上面的绑定契约三块竖排。

动手前先读《HTML报告生成规范》 —— 尤其 §4 绑定全表、§6 编辑态(闸 / 孪生件 / 增删行 / 红带)、§10 陷阱表。 三条最容易踩的:

  • 行容器的标签名不得在行模版内部再出现(用 <article> 包 <section>),否则行会被截成套娃;
  • 枚举列上下两态要说同一个字 —— 只读那一份别 data-slot 直显机器码,用 data-class-of 徽标或 data-by 查表;
  • 空格子的胶囊用 data-blank + CSS :empty::after,别在模版里判。

删掉 P08_MASTER_CREATE / P10_DOCUMENT_CREATE / EVAL_INPUT_LIST / DIFFERENCE_REVIEW / LANDING_CONFIRM 五个旧导出(Task 8、9 会新建同名的另两张),target-style.ts 里没有消费方的 CSS 常量一并删。

  • Step 4: 跑测试与全量门禁

Run:

pnpm exec vitest run activities/part-substitution-assessment > /tmp/green7.txt 2>&1; echo "EXIT=$?"; cat /tmp/green7.txt
pnpm gates

Expected: 全绿(整份贴进报告);退出码 0。

  • Step 5: 提交
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 件① 评估输入补全 —— 类型下拉从台账现读

芯片类型是人在件上现造的,选项写死等于「只能选出包那天就有的那几种」。
行由系统铺、人不加行:要建哪颗料是启动参数就定死的事,他只填格子。
主键列挂 data-col 当行的钥匙 —— 漏了会把人改的行错配到别行上(2026-08-02 踩过)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 7b: 件① 补上「新建芯片类型」—— 空表加行这条路要先通

这一条是 2026-08-29 从 Task 7 的顾虑里立出来的(控制端裁定)。Task 7 交付的件让人从下拉里挑 一个已有类型(data-options 现读),这条主路是通的;但 spec 明写的另一半 —— 人在这张件上新建一个类型 并定下它的核心参数字段 —— 没做。它不是可选的:

  • spec :37:「人选定/新建类型后按一次保存,系统按该类型的字段清单把参数值的行铺出来」
  • spec :261:提交时 chip_type / type_param / profile / parameter 四张一起进正式表
  • 用户原话里也点了名:「选择芯片类型(或创建一个新类型 + 它的核心参数字段,每个带描述与软硬约束)」

为什么 Task 7 做不了(诊断已由控制端核实,属实): ViewContainer.addRow(packages/ui-kit/src/ViewContainer.tsx:303-306)靠 host.querySelector('tr:last-of-type') 克隆最后一行,零行时返回 false; 而 mine:parts.chip_type / mine:parts.type_param 每单开局都是零行 —— 加行按钮到不了岸。 顺带:平台在这件事上自相矛盾 —— apps/api/src/app/routes/desk.ts:843 那条空表闸的话是 「先在件上加一行填好再提交」,教人做一件 UI 做不到的事。

三条候选路,开工前先定哪一条(各自的代价已初勘,但都要自己复核):

① 件上摆字面量空白行(不走 data-rows,直接写 data-edit + data-col 的行)—— 零平台改动。已核实两条支撑机制存在:空行不写(desk.ts:270 一带,filled.length > 0 才落)、 auto 主键由基座续号(type_id 声明 auto,不进表单)。 未解的坎:parts.type_param 是复合主键(type_id + param_name),而新类型的 type_id 要等 chip_type 那一行写下去才铸得出来 —— 跨行的自增键联动基座目前是逐行铸的。先查这条通不通。

② 平台补「原型行」:渲染器对零行的 data-rows 留一行隐藏原型(<tr hidden data-row-proto>), addRow 找不到 tr:last-of-type 时回落到克隆它。改动小,但动的是 view-render.ts 与 ViewContainer.tsx 两个共享面 —— E04 / I01 都在用,照规矩要全量回归。 顺带能把 desk.ts:843 那句自相矛盾的话变成真的。

③ 配方先种一行骨架:S1 在某颗料没有 chip_type_id 时投一行空 parts.chip_type。 零平台改动,但人若改从下拉挑了已有类型,那行空骨架会让 _missing > 0 一直卡着闸, 得他自己删掉才行 —— 摩擦大且不明显。列在这里是为了记下它被否过,别再想一遍。

Files: 待定(取决于选哪条路) Interfaces: 消费 Task 7 的 input-supplement 件与 S1 配方的 gateLedgers

  • Step 1: 定路线 —— 先查①的跨行自增键联动通不通(autoKeys 在 desk.ts:392 一带); 通就走①(零平台改动最省),不通就走②(并按「改共享面全量回归」办)。把结论写进报告。
  • Step 2: 照选定的路实现,并补测试
  • Step 3: 变异验证 —— 拿掉这条能力,证明测试变红
  • Step 4: pnpm gates(看真实退出码)→ 提交

Task 8: 件② difference-review —— 差异评估结论审核(验收工单)

Files:

  • Modify: activities/part-substitution-assessment/src/views/report.ts(新增 DIFFERENCE_REVIEW)
  • Modify: .../src/manifest.ts(viewTemplates)+ case-model.ts(S3_review.view)
  • Test: .../__tests__/activity.test.ts

Interfaces:

  • Consumes: Task 5 落下的 substitution.difference_item 与 substitution.relation
  • Produces: 视图模版 difference-review

绑定契约(spec §6.2):

区 绑定 可改?
对决头 data-slot="original_internal_pn" / candidate_internal_pn;类型名用 <span data-rows="parts.chip_type" data-by="type_id=chip_type_id"> 查表 只读
四态分布带 四个 data-slot 取 substitution_difference_item_difference_status_*;条宽用 data-class-of 投影成 class 只读
推荐等级大徽章 data-class-of="recommendation_level" + data-slot 只读(规则算的)
逐条明细 <tbody data-rows="mine:substitution.difference_item" data-edit="substitution.difference_item">;difference_item_id 挂 data-col 当钥匙 四态(<select data-col="difference_status">)· 影响说明 · 判断依据 · UNKNOWN 去向 可改
收口四格 推荐理由 / 差异摘要 / 风险 / 验证要求,各 data-slot 只读
  • Step 1: 写失败的测试

⚠️ 助手名照文件里真有的写(2026-08-29 控制端核正:计划原稿这里写的 viewOf / ASSESSMENT_STEPS 在这个测试文件里都不存在)。activity.test.ts 用的是:

  • viewHtml(name) —— 本地定义在 :38(manifest.assets.viewTemplates.find(...)?.html ?? '');
  • 取剧本走 manifest.assets.businessModel.steps,不是 import ASSESSMENT_STEPS。

这是刻意的:这个文件测的是装配出来的清单(成品),不是 case-model.ts 里的源常量 —— 源常量对得上而清单里漏装,正是它要拦的那类错。照它现有的写法来,别新引一套。 (ASSESSMENT_STEPS 确实是 case-model.ts 的真导出,但那是 steps.test.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('<script');
  });
});
  • Step 2: 跑一遍,确认它失败

Run: pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件②" > /tmp/red8.txt 2>&1; echo "EXIT=$?"; cat /tmp/red8.txt Expected: FAIL。整份贴进报告。

  • Step 3: 写模版

doc() + ACC1_CSS(验收件那一套)。题头主题色用对话区验收卡同一枚 #0aa294(2026-08-27 用户定:验收工单的题头是验收绿,不是红)。

这张件的收口四格必须只读:它们是系统按 §3.5 规则算出来的,人要改就去改上面那一条四态,系统重算(S3 配方里那两笔投影就是干这个的)。

  • Step 4: 跑测试与全量门禁 → 提交
pnpm exec vitest run activities/part-substitution-assessment > /tmp/green8.txt 2>&1; echo "EXIT=$?"; cat /tmp/green8.txt
pnpm gates
git add activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
feat(w05): 件② 差异结论审核 —— 可改的是四态,摘要与等级只读

推荐理由 / 摘要 / 风险 / 验证要求都是规则算出来的:让人直接改它们,
等于让他给自己没做的判断盖章。要改就改上面那一条四态,系统重算。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 9: 件③ landing-confirm + 运营中心看板重绑

Files:

  • Modify: .../src/views/report.ts(新增 LANDING_CONFIRM)
  • Modify: .../src/views/ledger-board.ts(LEDGER_BOARD 与 LEDGER_BOARD_DATA 重绑到新表)
  • Modify: .../src/manifest.ts + case-model.ts(T_release.view)
  • Test: .../__tests__/activity.test.ts

Interfaces:

  • Produces: 视图模版 landing-confirm;看板 activity-ledger 的 data 取数声明改绑新表

件③ 全只读,答「这一签下去发生什么」(spec §6.3):进哪几张表各几行(摆人读名)· 结论卡(等级 + 一句话)· 四态分布并把硬约束不符的逐条点名 · 待验项(P-06 的测试条件与后续动作)· 发哪一口。

  • Step 1: 写失败的测试

⚠️ 同 Task 8 的那条核正:这个文件里没有 viewOf,用本地的 viewHtml(name)(activity.test.ts:38); 取剧本走 manifest.assets.businessModel.steps,不是 import ASSESSMENT_STEPS。下面的示例代码照这个改。

describe('件③ 落账确认', () => {
  const html = viewHtml('landing-confirm');

  it('在 viewTemplates 里,T_release 指向它', () => {
    expect(html).not.toBe('');
    expect((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; view?: string }>)
      .find((s) => s.item === 'T_release')?.view).toBe('landing-confirm');
  });

  it('**全只读** —— 签之前只核对,不改', () => {
    expect(html).not.toContain('data-edit=');
    expect(html).not.toContain('data-col=');
    expect(html).not.toContain('<script');
  });

  it('把「进哪几张表各几行」摆出来,用的是基座读数', () => {
    for (const reading of ['substitution_relation_rows', 'substitution_difference_item_rows', 'validation_record_rows']) {
      expect(html).toContain(`data-slot="${reading}"`);
    }
  });

  it('硬约束不符的逐条点名 —— 签字的人最该看见的就是它', () => {
    expect(html).toContain('data-rows="mine:substitution.difference_item"');
    /*
     * **筛选交给样式表,不是 `data-by`** —— `data-by` 过滤的是「子列 = 外层那一行的父列」,
     * 这里没有外层行可依。做法:整表逐行渲出来,行上挂 `data-class-of="judgement_class"`,
     * 由 CSS 只显 `.v-hard_mismatch` 那几行(判断留在样式表,模版不判)。
     */
    expect(html).toContain('data-class-of="judgement_class"');
  });
});

describe('运营中心看板', () => {
  const board = manifest.assets.viewTemplates.find((v) => v.name === 'activity-ledger');

  it('取数声明改绑新表,不再引已退役的那几张', () => {
    const json = JSON.stringify(board?.data ?? {});
    for (const gone of ['demand.request', 'parts.document', 'sub_eval.pn_check', 'sub_eval.input_gap', 'sub_eval.verdict']) {
      expect(json).not.toContain(gone);
    }
    for (const kept of ['sub_eval.case', 'substitution.relation', 'substitution.difference_item']) {
      expect(json).toContain(kept);
    }
  });
});
  • Step 2–4: 跑红 → 写模版与看板取数声明 → 跑绿 + 门禁 → 提交

Run 红:pnpm exec vitest run activities/part-substitution-assessment/__tests__/activity.test.ts -t "件③" > /tmp/red9.txt 2>&1; echo "EXIT=$?"; cat /tmp/red9.txt

看板的 data 声明形状见《HTML报告生成规范》§4.1(case{ledger,key,prefix} + rows[{as,ledger,by,not,orderBy}]);案号前缀改成 W05-,行圈到:案卷 → 两颗料 → 类型与字段 → 参数对照 → 明细 → 关系 → P-06。

绿 + 门禁 + 提交(信息:feat(w05): 件③ 落账确认与看板重绑 —— 签之前只核对,硬约束不符逐条点名,附两行 trailer)。


Task 10: 退役 —— 两枚工具、两张判定表、技能戳

Files:(2026-08-29 控制端实地核过,原清单漏了四个文件,见下方 Step 1 的实测结果)

  • Modify: apps/api/src/app/tools/first-party.ts —— 摘掉 FIRST_PARTY_TOOLS 里那两行(:680-681), 连同只服务于它们的实现:compareParameters(:650)· recommend(:656)· compareParametersWith(:592)· ParameterLedger 类型(:515);parseParamValue(:393)/ describeParsed(:415)先查还有没有别的调用方, 没有才一并摘。留墓志铭。
  • Modify: apps/api/src/app/container.ts —— parameterLedger(:424-431)与那一行 handler 覆盖(:434)一并撤, 连 ParameterLedger 的 import。这一处原清单没有,不撤则工具在容器里照常注册。
  • Delete: apps/api/src/app/tools-w05.ts —— 整份就是这两枚工具的契约定义(W05_TOOLS / W05_TOOL_SAMPLE), 没有第三个消费方。原清单没有。
  • Modify: apps/api/src/app/seed-activities.ts(: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:

grep -rn "compare-parameters\|part-substitution-assessment\.recommend" activities apps packages --include='*.ts' \
  | grep -v "app/tools/first-party.ts" | grep -v "activities/part-substitution-assessment"

Expected(2026-08-29 控制端实地跑过,不是零命中):恰好这几处,全部在上面的 Files 清单里 —— container.ts:434 · tools-w05.ts:21,64 · seed-activities.ts:19 · seed-mvp.ts:15 · __tests__/first-party-substitution.test.ts 若干。

冒出清单之外的命中就停下来报上来,别硬拆。

  • Step 2: 改测试(先描述目标)

把 first-party-substitution.test.ts 里断言这两枚工具可用的用例,改成断言它们已停用且留了墓志铭:

⚠️ 计划原先在这里写错了(2026-08-29 控制端核出并改正)。 原稿写的是 registry.find((t) => t.name.includes(...))?.enabled).toBe(false) —— 三处都不成立: 该文件里没有 registry 这个名字;FIRST_PARTY_TOOLS(first-party.ts:669)是 ReadonlyArray<readonly [string, ToolHandler]> —— 元组数组,元素上没有 .name,取它得 undefined、 再 .includes() 当场抛 TypeError;而且本仓的工具压根没有 enabled 这个概念,退役的既有做法是 摘掉注册 + 留墓志铭(见 quote-archive / quote-intake 的 declarations 头注)。

断言改成**「不在注册表里」** —— 它同样拦得住「哪天有人手滑又把它注册回来」:

it('两枚工具已退役 —— 规则活在 S2 的配方里,不再有第二处真源', () => {
  const names = FIRST_PARTY_TOOLS.map(([n]) => n);
  expect(names).not.toContain('part-substitution-assessment.compare-parameters');
  expect(names).not.toContain('part-substitution-assessment.recommend');
});

这个文件其余的用例怎么办:它们逐条测的是 compare-parameters 的比对行为,而那段代码这一步就删了 —— 测试跟着它测的东西一起走,这不是「删测试让门禁变绿」(本仓禁止的是后者)。 把整份文件缩成上面这一条退役断言,文件头注改写成墓志铭:说清这两枚工具做过什么、 规则现在活在哪儿(case-model.ts 的 S2_assess.auto)、以及为什么不留第二份。

  • Step 3: 停用并留墓志铭

摘掉注册,不是「标成停用」(2026-08-29 控制端改正:本仓工具没有 enabled 这个概念,见 Step 2 的说明)。 按上面 Files 清单逐处摘干净 —— first-party.ts 的两行注册与只服务于它们的实现、container.ts 的 handler 覆盖 与 parameterLedger、tools-w05.ts 整份、两个 seed 的 import。摘完跑一次 Step 1 的 grep 复查,应当只剩墓志铭里的文字。

在 first-party.ts 原处留一段中文墓志铭,写清:它们算的四态与推荐等级现在活在 activities/part-substitution-assessment 的 S2 配方里(§3.5 的规则表),规则只许有一处真源; D01 / D02 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 就是同一件事两处各存一份。

删掉 skills.ts(全程无 agent,技能戳没有消费方),manifest.assets.skills 置空,contract.resources.skills 置空。

  • Step 4: 跑全量门禁 → 提交
pnpm gates
git add apps/api/src/app/tools apps/api/src/app/container.ts apps/api/src/app/seed-activities.ts \
        apps/api/src/app/seed-mvp.ts apps/api/src/app/__tests__ activities/part-substitution-assessment
# tools-w05.ts 是删除,`git add -A apps/api/src/app` 或显式 `git rm` 都行 —— 别漏在工作树里
git commit -m "$(cat <<'EOF'
chore(w05): 两枚工具与两张判定表退役,技能戳删除 —— 规则只许有一处真源

四态与推荐等级现在活在 S2 配方的 when 链里(spec §3.5)。
再留一份 DMN 或一枚工具,就是同一件事两处各存一份,改一处就分叉而没人看得出以哪份为准。
技能戳是给模型看的,而这个活动全程没有 agent。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 11: 装包、落库、五案例 e2e

Files:

  • Modify: tools/e2e-a2.mjs(SUITES.w05,约 550 行起)
  • Modify: activities/part-substitution-assessment/src/manifest.ts(samples 五条)

Interfaces:

  • Consumes: 前十个任务(包已完整)
  • Produces: 五条样例 + e2e w05 套件;真库上跑通

⚠️ 这一步会动共享库,开工前必须先过一道人

开发实例(:8787)与稳定版实例(:8790)共用 jcai_wos 库,而稳定版跑的是分支之前的代码。 装 W05@2.0.0 会把新清单写进 sys.activity_package,两个实例都读它 —— 而旧代码的 planStepSchema 要求 guide 非空(R8 那条放宽只在本分支上),它解析不了这份新清单。

用户已经定了(2026-08-29,原话「走查期间先停掉稳定版」)——走第 ① 条。 曾摆过的三条路留在这儿备查:①走查期间停稳定版;②另开一个库跑(代价是种子要重播); ③明知有风险照装(稳定版在此期间用不了 W05)。

执行者不要自己去停 —— 停稳定版是控制端的事(它是本工作树之外的副作用)。 开工前先确认 lsof -iTCP:8790 -sTCP:LISTEN 没有监听;还在监听就停下来报上来,别自己动手、也别绕过去装。 确认停了之后,pnpm db:migrate / activities:install 照跑(db:seed 仍需另行确认 —— 它会重播治理台配置)。

  • Step 1: 写五条样例

manifest.samples 五条,名 ≤14 字(超过 14 字运营端只显示「样例 N」,等于没名字),第一条是最长的那条路(测试台落地即预填的是它):

  samples: [
    { name: '候选缺库·新建类型', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
    { name: '都在库·零卡直分析', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
    { name: '类型不一致·硬拦', input: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, expect: { ports: ['assessment-released'] } },
    { name: '原PN缺库', input: { originalInternalPn: 'LM2596S-3.3', candidateInternalPn: 'LM2596S-ADJ' }, expect: { ports: ['assessment-released'] } },
    { name: '两颗都缺库', input: { originalInternalPn: 'CN-DCDC-32V', candidateInternalPn: 'XL2596S-5.0' }, expect: { ports: ['assessment-released'] } },
  ],

包内自测补一条:样例名都 ≤14 字、第一条是缺库那条。

  • Step 2: 重写 e2e 的 w05 套件

tools/e2e-a2.mjs 里 SUITES.w05 整体换掉。照该文件既有套件的形状写(先读 e04 / i01 两个套件,用文件里真有的键名),要覆盖:

  • 零卡直分析那条:断言一张数据工单都没发(第二条样例),直接走到审核卡;

  • 缺库那几条:数据工单派出后,件上按地址保存(buildEdits 按 data-edit 地址 + data-col 列名), 空手交卷必须吃 400(提交闸真拦),补齐后才过;

  • 类型不一致那条:断言 gate_reason 里点了名、且补不成同类型时提交仍被拦(这条是硬拦,不许绕过去);

  • 审核卡:单选项「确认通过」+ 人工验收清单要勾全才能通过(checked ⊇ checklist,不勾全应当被拒);

  • 放行:终局验收卡 → released → 发口 assessment-released;

  • 模型调用恰一次:从任务留痕里数 analyze 那一笔(全套只该有一次)。

  • Step 3: 装包并核库

在 Step 0 的确认拿到之后:

pnpm db:migrate
pnpm db:seed
pnpm activities:install

然后用 SQL 核库里的清单版本,不看脚本的话(同版重装是 no-op,脚本照样打印「已装」—— 踩过):

node -e "const{Client}=require('pg');(async()=>{const c=new Client({connectionString:process.env.DATABASE_URL});await c.connect();const r=await c.query(\"select version, enabled from sys.activity_package where activity_id like '%part-substitution%' order by installed_at desc limit 3\");console.log(r.rows);await c.end();})()"

Expected: 最新一行是 2.0.0。不是就先查为什么(多半是版本号没升,或另一个会话抢先占了号)。

  • Step 4: 跑 e2e
JCAI_E2E_LAND_MASTER=1 node tools/e2e-a2.mjs w05 > /tmp/e2e-w05.txt 2>&1; echo "EXIT=$?"; tail -60 /tmp/e2e-w05.txt

验收线:5/5 released · 发口全对 · 提交闸每单真拦(空手交卷吃 400)· 模型调用恰 1 次/单 · 配方零出错。 (「行动被拒 0」这条对无 agent 的活动自动成立,改数模型调用次数。)

跑完清运行数据:pnpm --filter @app/api reset-runs。

  • Step 5: 提交
git add tools/e2e-a2.mjs activities/part-substitution-assessment
git commit -m "$(cat <<'EOF'
test(w05): 五案例 e2e —— 零卡直分析 / 缺库 / 类型不一致硬拦 / 两颗都缺库

验收线:5/5 released、发口全对、提交闸每单真拦、模型调用恰 1 次。
「行动被拒 0」对无 agent 的活动自动成立,所以改数模型调用次数 —— 它才是这次要守的那个数。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

Task 12: 浏览器走查与收口

收口时顺手核对的文档口径(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 复核落账
node -e "const{Client}=require('pg');(async()=>{const c=new Client({connectionString:process.env.DATABASE_URL});await c.connect();for(const t of ['substitution__relation','substitution__difference_item','validation__record','sub_eval__case']){const r=await c.query(\`select count(*) from \${t}\`);console.log(t, r.rows[0].count);}await c.end();})()"

核三件:①明细行数 = 该类型的核心参数字段数;②关系一行,recommendation_level 与四态分布对得上; ③P-06 一行、validation_status = 'not_started'(本活动不虚构实测结果)。

  • Step 3: 收口文档

  • 《活动开发规范》:在 §4.6 后补一小节,把这个活动作为第一个全 auto 的范例点名(四步、零 guide、AI 一次), 并写下这次真正踩到的坑(至少:gate_reason != "" 那个未绑定名陷阱、chip_type_id 顶层同名冲突要对准一张表 lookup)。

  • docs/debt.md:记下这一轮欠着的(至少:{joinRows} 未实现所以验证要求不逐条列参数名; 类型字典的去重与合并没做,同一类型建两遍不会被拦)。

  • CLAUDE.md 现状快照加一条:ACT-WORK-05 已重写为四步全 auto,写清「AI 出场恰一次、行由字典决定不由它决定、 推荐等级由规则算」,以及五案例 e2e 与浏览器走查的实测账。

  • Step 4: 最后一遍门禁并提交

pnpm gates
git add docs CLAUDE.md
git commit -m "$(cat <<'EOF'
docs: ACT-WORK-05 重写收口 —— 四步全 auto 的第一个范例

规范补下这一轮真踩到的两个坑:S-FEEL 里未绑定的名字 `!= ""` 求值为 true
(拿 gate_reason 当发卡条件会在一切正常时把卡发出去);
顶层同名字段以「后写的赢」为准,取值要对准一张表 lookup 而不是赌哪张表最后写。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
EOF
)"

验收线

  1. pnpm gates 六道全绿(以退出码验,不许接管道)。
  2. e2e w05 五案例 5/5 released、发口全对、提交闸每单真拦、模型调用恰 1 次/单。
  3. 浏览器走完最长那条路:新建类型 → 保存 → 参数格自己出现 → 填齐提交 → 审核卡就地改四态 → 等级跟着变 → 勾全清单 → 放行 → released。
  4. 库里核过:明细行数 = 该类型字段数;关系的推荐等级与四态分布对得上;P-06 一行且 not_started。
  5. 活动包里没有一处 guide、没有一件工具、没有一面记账旗。