Compare commits

...
144 Commits
Author SHA1 Message Date
xcsandClaude Fable 5 7ed997187e fix(engine): 确认闸条件档在发卡装配被丢 —— 类型不一致的卡从前点得动
CI / ci (push) Failing after 11s
活动声明了 gateWhen,发卡装配的手写清单没点它的名:落库载荷 undefined,
按钮禁用与服务端拒收一起无声失效(手写字段清单第四次丢值,与 variant
上次被丢的同一段)。抽 cardConditionGate 校验着转交(5 测);e2e w05
数据卡阶段补末端断言 expectCardGateWhen。活体验证:载荷带条件 → 闸关 →
API 强行确认 400;e2e 案例 0 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 11:09:48 +08:00
xcsandClaude Fable 5 0e02bb9411 fix(task): 终态收尾不等蒸馏 —— 终止接口曾被模型调用吊 13-45 秒到报错
CI / ci (push) Failing after 10s
四单法证:finished_at 与蒸馏条目 created_at 差 13.2/36.3/43.4/44.6 秒。
finish 把终态落库并广播之后同步 await memory.distill(一次真模型调用),
终止路由与放行收尾拍(连带推进器租约)都陪着模型等。改后台跑:
封卷仍在前,失败照旧吞掉不掀终态;三测钉死,改前即 30s 超时。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 03:36:53 +08:00
xcsandClaude Fable 5 3b9c559a47 fix(w05): 落账席那枚印章不再压住旁边的结论 —— 徽章按圈的内径收
用户:「评审放行与落账确认,中间这个有条件推荐这个 svg 挡住旁边的字了」。

在浏览器里量的账:徽章那一坨 .seal-core 139px 宽,而印章圈定死 112px —— 溢出 26px;
右边那句一句话结论从 603px 起,正好被压住。病根是徽章按自己的字宽撑开、又带着
white-space:nowrap 缩不了,而圈是定死的圆(place-items:center 只让它对称地溢出去)。

两条一起治:核心不许超过圈的内径(max-width:calc(100% - 16px));圈里那一枚字号收小、
去掉字距、允许折行(以后有更长的等级名就两行,圆里两行照样是圆)。
改完再量:核心 94px、圈内还余 9px、「有条件推荐」一行放得下、结论那段完全露出来。

验收法记一笔:视觉的账在**真 DOM** 上量 —— 离线渲一份真件挂本地服务,
浏览器里 getBoundingClientRect 前后各取一次,不在脑子里推。包内 +1 测(变异验证过)。
2.24.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 03:06:42 +08:00
xcsandClaude Fable 5 4174c5f194 fix(w05): 调人预算提到 24;「一条都没点到」的打回不再整表重判
用户:「怎么直接最后落帐的步骤直接没了?」—— 那一单终态是 exhausted:结论审核连打回四次,
每打回一次配方就重发一张审核卡、每张 +1 humanCalls,第八次触顶,放行那一步再没机会出现。
屏上看着像「那一步没了」,其实是单子先被烧死了。

① 预算 8 → 24(用户当场定「那你就把预算提高」):常路只要 3 次(补数据 / 审核 / 放行),
   而审核每打回一次就多叫一次人。授权面与资源面同一个数;测里改成「两处一致 + 下限 12」,
   不再钉死某个具体数字。**这是配置不是机制** —— 机制上的正解是「人自己打回引出的那张卡
   不算打扰」(他不是被打扰,是他叫的),已记 debt。

② 「一条都没点到 → 整表算被点名」当天上线、当天撤掉:他那句是「收口结论里面的推荐理由
   改为非常不推荐」—— 说的根本不是某条参数,是收口那四格(本来就能在件上直接改)。
   整表重判的下场:AI 拿这句话把 5 条参数的结论改成「不匹配」,每条依据都写着
   「按审核人意见调整为不匹配:推荐理由改为非常不推荐」——一句与它们无关的话,改了五行。
   现在点不到名就一条都不改,并照实说清怎么才办得成(点参数名 / 收口四格在件上改)。
   重判与写回同批加「真点到了名」的前提,免得白烧一次模型。

验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿;2.23.0 装库核过(humanCalls=24)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 02:55:05 +08:00
xcsandClaude Fable 5 3c563525f1 fix(engine): 那面「打不回」的旗在容器里被扔了 —— 手写字段清单第三次丢值,改成整包转交
用户:「评审放行与落账确认这一步的验收卡怎么还有重做??你没改上吗?」——
库里的卡确实没有那面旗:装配层算对了、release 转对了、契约也声明了,而**容器那个显式对象**
只转它点到名的那几样,`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。

同一处已经吃过三次:①fields 写死成 [](验收报告只能看不能改)②blocks 漏一行(卡面块恒空)
③这次。那几行旁边就写着两条「少写一行就静默落地」的警告 —— 措辞治不了的结构治:
context 三样拆出去、**其余整包转交**,以后端口新增可选字段不改这里也到得了。
断言钉在最末端:试跑观测面的卡带上 redoable,e2e 断言「一路到了卡上」。

同批修另一种时序的卡死(同一夜第二次,停掉了一整个 e2e 案例):
**决策卡的回传不发事件、只调 wake**,而 wake 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫
(实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,停到 20 分钟超时)。
修:配方发卡收尾前再看一眼刚发的那张卡还在不在等 —— 已经答了就照实说「还在跑」;
只认这一张卡,别的「等外部回音」照旧是等,不会空转成热循环。
假端口同批改老实:发出去的卡默认就在等(从前发完卡还回「一张待答的都没有」)。

验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿(不摆重做 / 对话区说了话 /
点名要的结论落下去了 / 只动被点名的行);两处变异验证;契约变更 api-report 已刷新。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 02:36:53 +08:00
xcsandClaude Fable 5 e474275b28 fix(w05): 复核轮审核人说了算,并把这一轮改了什么说进对话区
用户第二次实测:「重做还是没有修改掉内容」—— 他写「静态电流结论改成不匹配」,而两颗料的
静态电流都是 5mA,AI 搬出第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去:
evidence_note 白纸黑字「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。

**行的范围这次是对的**(只把那一条交给 AI、只写回那一条),错在**谁说了算**。三处想岔了:
① 那条规矩管的是它自己判的时候别被措辞带偏,不是用来否决审核人的;
② 方向也反了 —— 它守的是「不许把不满足说成满足」(危险方向),而他要的是收紧结论;
③ 更根本:审核人在件上本来就能亲手改任何一条四态,AI 在重判这条路上顶回去,
   等于同一件事在两条路上给出相反的答案。
改:复核指令明写这条覆盖 —— 他点名要什么结论就落什么,不要顶回去;落的同时 evidence_note
记两句「按谁的意见」+「数据面是什么」,下一个人回看能一眼分清数据判的还是人定的。

同批(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」):
复核完在对话区说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,摆的是件上那一行
自己的话,不另编一句。**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 say 要等
下一拍才跑:原来那句「差异结论已送审,请核对」实测正是在人答完之后才出现的;而且它与卡自己的
ask 本来就是同一件事,一并退役。

验收:e2e w05 5/5 released、被拒 0;案例 0 三条断言全绿 ——
「复核那一轮在对话区说了话」「打回点名要的结论真落下去了(使能阈值 = conditional)」
「打回只动了被点名的那几条」。包内 +2 测。W05@2.21.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:57:52 +08:00
xcsandClaude Fable 5 cec19adc85 fix(engine): 打不回的验收卡不摆那颗钮 —— 全 auto 的放行步没有模型能接住那句意见
用户:「评审放行与落账确认这一步的验收卡不应该有重做的选项,因为这里都没有接入 ai」。

W05 的 T_release 整个由配方跑完(guide 是空串,不问模型),件又是只读的 ——
打回之后没有任何东西能接住那句意见:配方只会原样再提交一次,撞在「打回之后台账没动过
就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的兜底卡。
一颗按下去只会把单子推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。

· 契约:AcceptanceWorkOrder.redoable(不写 = 打得回,老卡老行为);
· 装配层按放行那一步的声明现算 —— releaseRedoable 纯函数(有 auto 且 guide 空 → 打不回;
  半 auto 与模型驱动照旧打得回,那儿真有模型会读意见),与 decisionCardFace /
  executionCardVariant 同一种做法、同一个测试位;
· 卡面收起理由框与那颗钮,换一句真路:「这一步只落账,退不回上一步 —— 有异议就别确认,
  去终止这一单重开」;
· **服务端才是门**:redoable:false 的卡收到非 pass 的回传当场拒(redo-unavailable),
  直调 API 绕不过;拒回把真路指出来。

S3 结论审核那张审核卡不受影响 —— 它的重做真会带着意见重判(昨夜那一轮)。
测:card-rules 3 条 + releaseRedoable 4 条,两处变异验证;e2e w05 5/5 released、被拒 0。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:34:16 +08:00
xcsandClaude Fable 5 8314148ebc fix(w05): 重判的行由确定的一侧圈 —— 打回点名工作温度,不许去改静态电流
用户实测:「我打回让修改工作温度的结论,给我改了静态电流的结论」。库里的证据一字不差:
工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足 —— 这半截是对的),
而没人问过的静态电流被它从 soft_mismatch 翻成 match,evidence_note 里却写着「维持原判」。

根因不是提示词不够严,是**整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
而「我只改了那一条」这句话本身也出自它嘴里。规范 §4.6 纪律一(行由确定的那一侧决定,
不由 AI 决定)—— 昨天刚加的重判轮自己栽在这条上。

S3 复核轮两笔变四笔(全部只在 case_redo_note != "" 那一轮跑):
① 圈范围:按字面点名(contains,与基座给模型摆的「意见点名对上了哪几行」同一种认法)标
   named / kept(明细表新增 redo_scope 列);
② 兜底:一条都没点到(意见说的是整批)→ 整表算被点名,读上一笔刚写下的 tally,不重算;
③ 重判只喂 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着(也省 token);
④ 写回也只写这几行;模型漏答某一条时 else 兜回它上一轮的值。

同批修一条挡了两次路的老毛病(昨夜刚记 debt,今晚又撞停、还卡住了我自己的验证):
人答得比发卡那一拍收尾还快时,回传被判 stale、wake 又被那一拍的 park 覆盖,单子停在
awaiting-event 再没人叫醒。deliver 判了 stale 照旧不跑这一拍,但把停着的单子拨回 running。

验收:e2e w05 5/5 released、被拒 0;案例 0 新断言「打回只动了被点名的那几条,其余行一个字没变」
真跑通过(途中逼出脚本自己的坑:放行会清空草稿层,快照得留在放行之前);
模型调用 案例 0 = 2 次、其余 = 1 次。四处变异验证。W05@2.18.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:17:09 +08:00
xcsandClaude Fable 5 4d2c3657f9 feat(w05): 重做的意见交回给 AI 重跑那一步 —— 不再只是把派生列重算一遍
用户:「唯一调用 ai 的这一步,使用重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」
确实没有:S3 那一轮只跑三笔纯投影重算,人写的那句话除了留痕之外无人读 ——
AI 判过的四态一个字不会变,除非他自己在件上逐格改。

平台:引擎把「人这次打回写了什么」注给配方(`case_redo_note`)。
· 下划线不是 case: 前缀 —— S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 when 条件用;
· **没打回也永远绑着空串**:未绑定的名字 `!= ""` 求值为真,不绑就等于那几笔每一轮都跑
  (拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住);
· 静态审认这个名字 —— 它当场拦下过一次未登记的引用,这道门做对了事。

W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 ——
带意见重判(入参多带上一轮四态/影响/依据/UNKNOWN 去向 + 他那句话;判法与第一轮同一份
ASSESS_RULES,后接复核纪律:没被点到、也确实没判错的行原样带回,别趁重判把人改过的冲掉)
+ 判完落回明细(与第一轮同一笔投影,只多一个 when)。判法、出参形状、落明细抽成三个
共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。

验收:e2e w05 5/5 released、被拒 0;案例 0(先点名驳回、再通过)模型调用恰 2 次,
其余四例仍是 1 次 —— 没被打回就不多花一次模型。auto-runner 3 测(含变异验证)、包内 +1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 00:33:23 +08:00
xcsandClaude Fable 5 f5435f4c9c fix(engine): 人点「重做」不是「办完了」—— 打回即重开一轮,不再一路推到放行
用户实测:「我重做后怎么直接推进了?不应该是重做刚才那一步吗?」那一单的轨迹逐条对得上:
审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」→「自动收步:S3_review」
→ 下一拍发出落账放行的验收卡。意见落进了轨迹(还被 note-anchors 逐行对上了),
而再没有任何东西会去读它。

这条缺陷 2026-08-30 全分支终审已记录成 Important 2、并留着一条坐实病灶的测试,
连「半个修法会把单子卡死」都预判对了。照那个方向修完,两半缺一不可:
① 收步那道门认答复(answerSettlesStep:custom:(人没选任何选项、写了自己的话)与
   约定的 reject 都不算办完;空答复照旧算「照原样确认」);
② 同一句判断也让 carded 让位 —— 幂等挡的是「同一问被重复发出去」,而人点重做正是说
   「这一轮不算,改了再问我一遍」:配方重跑(该重算的重算)再发一张,日志里说一句
   「人把这一步打回重做:<意见>」。

测:坐实那条改写成钉死修复后的行为(历史诊断原样留着);新增「重做但发不出新卡也不收步」
(这一条专管收步门那一半)与「通过照旧收步」;3 条纯函数测。两半各自变异验证。
e2e w05 5/5 released —— 案例 0 现在真的发出两张审核卡(先带意见驳回、再确认通过)。

顺带撞见一条独立的老毛病,已记 debt 未修:人答得比发卡那一拍收尾还快时,回传被判 stale、
wake 又被那一拍的 park 覆盖,单子停在 awaiting-event 再没人叫醒(手工拨回 running 立刻走完)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 00:03:33 +08:00
xcsandClaude Fable 5 445aa5ef22 fix(w05): 验收工单的面孔在装配层被丢掉 —— 两头都有测,中间那一段把值扔了
用户实测:「你怎么给我发成了执行工单,我需要的是验收工单,然后标题那一块需要是
验收卡的那种绿色」。

一、面孔:engines.ts 发执行工单那一处是一句写死只认 'data' 的三目,配方递下来的
'accept' 静默丢掉 —— 对话区照旧摆「执行工单」。**配方那一头有测(variant 原样递出去)、
前端那一头有测(accept 该穿哪副面孔)、e2e 按标题驱动也全绿,唯独中间没有。**
修:抽成纯函数 executionCardVariant(与旁边 decisionCardFace 同一种做法、同一个测试位),
认 data / accept、拒 review、认不出的当没写;4 测变异验证过。
e2e 同批改**认面孔不认标题**(试跑观测面的 cards 补 variant,契约变更)——
标题是活动自己写的字符串,卡穿哪副面孔由装配层决定,那一次正是两者对不上。

二、颜色:件① 验收工单那一副整套强调色换成验收绿 #0aa294(令牌从 ACCENT_LEDGER
现改选择器得来,与件②③、对话区验收卡一处真源),题头那条写死的浅蓝渐变单独给一条绿的;
数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。

验收:e2e w05 5/5 released、被拒 0(面孔断言这一轮真在跑);2563 测、四门禁绿;
2.16.0 装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 23:26:09 +08:00
xcsandClaude Fable 5 ce72da77eb feat(w05): 数据齐了也叫人一次 —— S1 改派只读的验收工单,件同一份、编辑钮收起
用户:「就算数据是足的也进入这个数据工单,就派发一个验收工单,界面和数据要和现在的
第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉,只展示不能编辑,然后把标题旁边的
数据工单改成验收工单」。

病根在引擎拍首那道 doneWhen 扫描:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,
配方一笔都还没跑,这一步当场被收掉 —— 卡永远发不出去,人一眼没看过就进了 S2。
治法:CMMN 给 S1 标 manualActivation,那道扫描多一个门槛(worldTouched),次序变成
「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 仍是真门,只是不抢在人前面。

平台:执行工单第三副面孔 variant:'accept'(件只读、人只确认「确认无误」)——
与 mode:'acceptance'(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断
抽成纯函数 order-face.ts(有测),chat-bubbles 只摆。
活动:S1 两笔互斥的 card(carded 幂等保证一步只发一张)+ 案卷粘着的旗 order_kind;
一张件两副面孔 —— 题头/标题/页脚三处名字与卡角编辑钮按 v-data / v-accept 由样式表挑
(藏的是容器 .fabs,逐枚去盖打不完)。

测:引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、卡发不出);
auto-runner variant 透传;order-face 4 条;包内 2 条(两笔卡 + 粘旗)、view 1 条;
两处变异验证。e2e w05 5/5 released、被拒 0、模型调用恰 1 次 —— 都在库那例摆的正是验收工单。
2558 测、四门禁绿;2.15.0 装库核过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 22:44:00 +08:00
xcsandClaude Fable 5 e47ce92702 fix(w05): 两颗料不能是同一颗 —— 起单这一刻立墙,因为路只在这里
用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。
查下来这不只是没意义,是**把单子做死**:sub_eval.part 的主键是 eval_ref+internal_pn
(role 不在主键里),S1 配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行 ——
sub_eval_part_exists_status_found = 2 永不成立,S1 收不了步,而件上那两行人加不出来
(行源 mine:,没有「+加一行」),唯一出路是终止重开。与「两格留空」是同一条链。

平台加互斥声明 x-distinct(契约 distinctGroup)——与 x-link 正相反:那个是引导,这个是墙。
· 前端:别的格占着的候选这一格不列(自己填着的那个永远留着,否则 select 当场变空白),
  并说清是谁占了哪个;走「新增」自己写一个同样的 → 小红字 + 启动按不下去;
· 服务端 validateLaunchParams 独立拦(脚本 / API 绕不过),400 点名后填的那格;
· 「算不算同一个」= 去空白 + 不分大小写(launchDistinctKey,前后端同一把尺)——
  只挡精确相等的话 lm2596s-5.0 照样进去,混一颗垃圾主数据出来。
W05 两格挂 x-distinct: 'part',2.14.0 已装库核过。

测:服务端 6 条 + 前端 8 条(含联动与互斥合起来的真形状)+ 包内 1 条,三处变异验证过;
四道门禁绿(2548 测)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 22:03:56 +08:00
xcsandClaude Fable 5 8c5f3a7736 feat(activity-ops): 启动表单两格按芯片类型联动 —— 候选带分组键,先填的那格是锚
CI / ci (push) Failing after 10s
契约:launchOption 加 group/groupLabel(x-options.group 从字典表现读人读名)、
launchParam 加 linkGroup(x-link 结组);api-report 已随 --update 入库。
前端 launch-link.ts 纯函数管三条口径:锚摆全量、其余按锚的分组键收窄
(未标类型殿后一组,新增照走);锚粘着 —— 清空只解除收窄不换锚,人改得了主意。
服务端不据此立墙:combo 本就收候选之外的值,类型一致归 S1 收步判据
sub_eval_part_distinct_chip_type_id = 1(数得出来的门)。
W05 清单两格挂上 x-link: chip-type;文档同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 18:57:29 +08:00
xcsandClaude Fable 5 04ec718a66 docs(w05): gate_state 开关列入快照
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 18:05:54 +08:00
xcsandClaude Fable 5 228739eaca fix(w05): 都齐了红条整条收起 —— 开关是案卷真数据 gate_state,不看那句话空不空
2026-08-31 用户:「没有必填字段不需要警告了,还差什么这个红色区域就不要显示」。
病根在一层机制之下:配方把 gate_reason 清成空串时,**空值按列归型不落笔**
(jsonb 合并保旧值)—— 那句话一旦写下就永远清不掉,红条亮过就灭不了。
治法:案卷加开关列 gate_state(tally open/clear,两个值都非空、两个方向都真写),
与发卡条件共用同一句判定(GATE_OPEN_WHEN 一处真源);红条挂 data-class-of=gate_state,
v-clear 整条收起(判断留在样式表);人补齐最后一格保存,quiet 拍照跑 project、
重推件,红条当场消失。空文本 :has(:empty) 兜底保留(首拍还没写过的形)。
107 包内(开关双向 + 一处真源两锚,else 侧变异红过)+ 定向 1109 测;
2.13.0 装库核过(开关/CSS/else=clear)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 18:05:53 +08:00
xcsandClaude Fable 5 9412dd7aa8 docs(w05): 互换动画第二稿入快照
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:53:31 +08:00
xcsandClaude Fable 5 68faeb4f50 fix(w05): 互换动画提到与卡同级 —— 三列网格中间留实体动画道,卡片相应收窄
2026-08-31 用户:「不要把这个动画做到背景板上,会被卡片覆盖;和卡片做到一级,
可以压缩一点卡片的大小」—— 上一稿的对向穿行点走在卡后,只在缝里闪现,基本看不见。
.dossiers 改三列网格(minmax(0,1fr) 76px minmax(0,1fr)):卡片各占左右列自然收窄,
中间 76px 是**谁也盖不住的实体动画道** —— ⇄ 徽居中轻呼吸,两枚信号点绕徽轨道对转
(此上彼下绕圈 = 两颗料换位),底下的虚线总线在道里露出来把两张卡接起来。
窄屏(<620px)卡片竖排,动画道随之收起。浏览器两帧相位实证在转;
105 包内 + 2531 全仓;2.12.1 装库核过(orbit/lane)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:53:31 +08:00
xcsandClaude Fable 5 1de4ab9862 docs(w05): 第六轮入快照 —— gateWhen 条件档 / 量高可缩 / 互换动画
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:44:03 +08:00
xcsandClaude Fable 5 c4219a7b7d feat(w05+platform): 确认闸条件档 gateWhen —— 类型不一致不许确认;量高改可缩;卡间互换动画
2026-08-31 用户三连:

① 「提示两颗料不是同一个芯片类型…就不应该能写入并继续下一步」——
  缺格闸只数必填空不空,管不了跨行一致性。平台加**确认闸条件档**:配方 card 声明
  gateWhen(S-FEEL,只读基座读数)+ gateWhenSay,受理回传前在 submitGate 现算
  (rows/missing/tally/distinct 全套读数按声明表现读),为假 400、拒回用发卡人那句话;
  装包静态审与动作级 when 同一套审;评估不出的条件不成墙(坏表达式装包就拦)。
  W05 钉 parts_profile_distinct_chip_type_id <= 1 —— 钉 profile 本表(草稿现读零滞后;
  钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦),profile.chip_type_id
  加 distinct 声明。e2e 案例 2 的空手探针从此吃的就是这道闸。

② 「页面底部没东西还能向下滑一截」—— ViewContainer 量高从
  max(documentElement.scrollHeight, body) 改 body 优先:documentElement 以 iframe 自身
  高度为下限,内容缩回后它缩不回去,量高**只涨不缩**(参数编辑态把卡背拉长过一次,
  底部从此留一截空白滚动)。body(margin 0)才是内容自己的高。

③ 「两张卡片中间做出可替换的动画」—— 单点单向信号换成**对向双信号点**(左右互穿,
  走在卡后、只在缝里现身)+ 中央 ⇄ 徽轻呼吸 —— 这界面是判「可否替换」的,动画说的
  就是这件事。reduced-motion 全静。

测:契约 gateWhen 解析 + runner 透传(变异红)+ conditionGateOf 3 例(含坏表达式不成墙)
+ steps/activity 锚;2531 全仓四门绿;2.12.0 装库核过(gateWhen 入库/swapfx);
:8787 已重建重启。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:43:08 +08:00
xcsandClaude Fable 5 7515885afa docs(w05): 第五轮入快照 —— scopedTargets 按卡编辑 / 顶条合并点名
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:01:40 +08:00
xcsandClaude Fable 5 26db6c685b feat(w05): 按卡独立编辑 + 缺项点名并进顶条 + 区序换回 + 类型层叠图
2026-08-31 用户四连:
① 「各自编辑各自的部分,不要做联动」—— 平台新增 scopedTargets(ui-kit):开合作用域
  跟着触发钮走,三档 —— 钮在本地址宿主的行里→只这一行(A 卡的笔只开 A 卡);钮在别的
  宿主的行里→行内属于本地址的宿主(这张卡的卡背);外部闸行→全宿主(老行为,② 的闸
  不受影响)。收值与保存不随作用域收窄(collect 仍按地址全量,「只写真改了的行」在
  服务端把关)。编辑态/锁面/笔盘显隐 CSS 全部改成按卡(.dossier.on);同地址同时只开
  一张卡(笔在别卡编辑中自动隐去,快照键按地址,双开会互相踩)。2 测 + 变异验证。
② 逐块「还差 N 行必填」红带撤下,点名并进顶部「还差什么」—— 同一批基座读数
  (_missing/_missing_keys)在顶条出两行子行,数值 0 自己消失;gate_reason 改口。
③ 比对基准与芯片档案区序换回(基准在前)。
④ 比对基准换「层叠双封装」SVG(后面那枚淡出成剪影 —— 一类而非一颗),不再与芯片卡
  共用同一枚封装图。

测:ui-kit 35(scoped 2 新,变异红过)+ 包内 105 + 2528 全仓;2.11.0 装库五探针核过
(合并条/无旧红带/类型图/按卡 CSS/基准在前);浏览器实测 XL 卡独自翻背进编辑、LM 卡纹丝不动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:01:06 +08:00
xcsandClaude Fable 5 05ebecb541 docs(w05): 第四轮入快照 —— 笔↔软盘 / 只选不建 / 浅蓝题头
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 16:42:10 +08:00
xcsandClaude Fable 5 51914292dc feat(w05): 数据工单第四轮九连改 —— 笔↔软盘卡角编辑、只选不建、浅蓝题头、镜像修数字
2026-08-31 用户一次点齐九件,逐条落:
① 两行编辑闸退役 → 卡右上角笔(开当前这一面的编辑)↔ 软盘(保存)+ 叉(放弃);
  按钮就是平台的 data-edit-open/save/cancel 触发器,快照/取消机制原样,按面显隐全在样式表。
② 保存一瞬间下拉变数字 → ui-kit mirrorTwins:下拉镜像**选项文案**不镜 value,并把
  selected 属性拨到人选的那一项(守卫 CSS 与取消回显都认它)。2 测。
③ 参数输入框占位提示 → 渲染器 twinInputs 认 data-ph 落成 placeholder(平台小扩面,1 测);
  值格「规格书上的值 · 查不到留空」,说明格「补充说明 · 选填」。
④ 参数说明空着不再摆「建议」→ 什么都不摆(连兜底破折号一起收)。
⑤ 区序对调:芯片档案在前,比对基准在后。
⑥ **新建芯片类型整条路移除**(只能选已有类型):抽屉与两张编辑表(种子行/Critical-1
  修法/thead 回归网)退役,S1 配方 ②③ 自投影删除(八笔→六笔),gateLedgers 收窄到
  profile+parameter,writes 同步,gate_reason 与指导书改口「只选不建」;样例改名
  「候选缺库·建档定型」;e2e 案例 0 改走选既有 type_1(type_id=90 那套绕坑法随路退役)。
  退役面的七条钉测按仓例 it.skip 留墓志铭(钉的是已移除的路径,不是放松断言)。
⑦ 题头回浅蓝渐变(原目标件那条 120°),深色夜景只留验收两件。
⑧ 对话区执行卡不再摆「参考信息」板块(basis 数据照旧随卡走,详情/归档仍有)。
⑨ 比对基准降噪:字段收成丸(名+硬软徽+单位),说明藏进悬浮气泡指上即看;
  类型说明一行截断、悬浮展开。

测:ui-kit 33(mirror 2 新)+ 渲染器 61(ph 1 新)+ 包内 104(38 steps 重排)+ 2525 全仓
四门绿;2.10.0 装库 SQL 六探针全中(hd-light/软盘/data-ph/零表格/无 chip_type 编辑面/
gateLedgers 两张);:8787 已重启载入 data-ph 渲染器。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 16:41:14 +08:00
xcsandClaude Fable 5 f6c8dccb4f docs(w05): 第三轮入快照 —— 去表格化翻面卡 / 多宿主收值
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 15:37:28 +08:00
xcsandClaude Fable 5 66be5bab6e feat(w05): 数据工单去表格化 —— 翻面芯片卡(背面参数就地改)+ 基准瓷贴卡;平台多宿主收值
2026-08-31 用户第三轮:「界面上东西太多,做成几个区域;类型与核心参数字段做成和芯片卡
差不多的卡片;芯片卡与它的核心参数值合并 —— 点击翻转,背面写核心参数,悬浮要看得出能翻;
这个界面都不再需要表格来展示数据了」。

视图(主画面零表格,表格只活在新建类型抽屉里 —— 表单要加行,加行机制认 tr):
· 比对基准卡:类型丸 + 描述 + 字段瓷贴(名/硬软徽/单位/说明),与芯片卡同语言;
· 芯片卡翻面:正面主数据、背面本颗芯片的核心参数瓷贴(值必填红 / 说明建议橘)。
  翻转纯 CSS(checkbox + :has,零脚本;checkbox 无 data-col,collect 不认它),
  悬浮出「点击翻面 · 核心参数」提示丸 + 侧倾预告;主数据编辑锁正面、参数编辑自动
  翻背面(reduced-motion 下瞬时切换);说明四条收进折叠。

平台(ui-kit ViewContainer,多宿主收值):「参数值印在每张卡背、就地可改」= 同一张表
的行分居两张卡 —— 一个地址一个宿主装不下。hostsOf(querySelectorAll)+ 收值/开合/
镜像/快照全部按宿主清单走;**格子只归最近的 data-edit 祖先**(不然卡背参数会被主数据
的闸收走并进主数据行 —— collect-nested 钉过的静默丢数据换脸回来)。嵌套 data-rows
不带 data-edit 时行为原样(两条旧钉测不动);addRow 仍单宿主(会加行的表都是单宿主)。

途中踩了三脚都逮回:CSS 注释里写了「data-col=」原文把 ②③ 的「全只读」字面断言踩翻
(选择器与注释都不许出现);.flip-cue i 特异度压过 .cue-back{display:none} 两枚提示
丸同时冒出;host 计数断言错把渲染后的数当模版源码的数。

测:ui-kit multihost 3 条(排除规则变异验证)+ 包内 110(翻面/无表格/瓷贴新锚)+
2528 全仓四门绿;2.9.0 装库 SQL 核过(flip/back_host/ftiles/cue/主画面表数=2);
浏览器实测:点卡真翻(背面 5 瓷贴)、参数编辑自动翻背双卡成表单、LM 全值回显。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 15:36:39 +08:00
xcsandClaude Fable 5 794af58902 docs(w05): 第二轮入快照 —— 基准合一卡 / prune:'live' / 蓝图板底
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 14:48:23 +08:00
xcsandClaude Fable 5 11375eaf93 feat(w05): 比对基准合一卡 + prune:'live' —— 类型与字段跟手,只显当前那一种
2026-08-31 用户第二轮:「芯片类型列表还是有问题…和核心参数字段不能合并成一个卡片吗…
两个芯片都选一样的就应该只显示一种,现在只会跟着变多不会跟着变少」+
「卡片下面那个容器黑色和页面背景不搭」。

① 比对基准卡(类型 + 字段合一,只读、跟手):不再列 mine: 草稿镜像(镜像里积着改选前的
  旧类型 —— 撤行只在确认那一拍跑,F3 不许在人编辑时静默删行),按案卷锚
  (sub_eval.case.chip_type_id,配方每拍现算)嵌套查字典:卡上改选、按保存,quiet 拍
  project 照跑,这张卡当场换脸 —— 两颗料同类型时恰好只有那一种;没人选时空态指路。
  新建类型收进 details 抽屉(零脚本),两张编辑表原样在抽屉里(种子行/加行机制不动)。

② 契约新增 prune:'live'(quiet 拍也撤):只给**人不会在件上加行**的表用 —— F3 防的是
  「人刚加的行被撤」,人加不了行的表没有这个风险,而「改了类型,旧类型的参数行还摆在
  屏上」是另一种撒谎。W05 参数骨架 ⑤ 改 'live':改选类型,参数行跟手换。
  static-audit 的 prune×humanWrites 审计盖住 'live';规范 §4.6 纪律 5 补这条反面特例;
  两档都跳过已落地的行并出声(skipLanded)。

③ 板底改浅色蓝图:深色容器与纸面色阶断裂(用户点名),换强调色极浅晕染 + 淡格线,
  走线与信号点用强调色 —— 电路语汇留着,整页只剩 hero 一处深色。

测:契约 'live' 解析 + runner quiet-live 对齐(变异验证)+ 基准卡锚(变异验证)+
steps ⑤ 钉 'live';108 包内 + 全仓四门绿;2.8.1 装库 SQL 核过;:8787 已跑新构建
(watcher 又断,按应急程序手起 node dist/main.js)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 14:47:25 +08:00
xcsandClaude Fable 5 5a7bb9b58f fix(w05): 数据工单三病一改 —— 档案卡上桌、类型守卫、prune 跳过已落地
2026-08-31 用户实测三连 + 「设计只是头上加了动画,操作感没变」:

① 类型「自己给上了」:data-by 行缺键落回外层同名字段(debt I3),查名 pill 摆出
  案卷锚(原 PN)的类型 —— 这一行根本没有的值。守卫认 selectCurrent 按行作用域写的
  option[selected]:真选上才摆名,没选摆「待选择」。守卫必须单层 :has ——
  :has() 里嵌 :has() 是非法选择器,整条被浏览器静默丢弃(第一版栽过,预览截图逮回)。

② 「品牌…点保存根本不会保存」:块① 行源是 sub_eval.part,那张案卷表没有主数据列,
  renderTemplate 只认行作用域 —— 保存真落草稿,显示与孪生件永远空白(四步重写后的
  潜伏缺陷:e2e 走 API、浏览器走查在 swap 版上,两条验证线都照不到,真人一填才现形)。
  行源改 mine:parts.profile 本身(S1 ④ prune:true 恒有两行),行里天生有全部列。

③ 「改回同一类型,下面还是两个类型」:②③ 自投影为绕「已落地行撤不掉」的死锁
  (2026-08-30 e2e 案例 2)特意不带 prune,没落地的陈旧行也跟着积在屏上。
  治在平台:draftDrop 加配方专用旗 skipLanded(跳过并出声「N 行已随数据工单入库、
  这一笔不清它」,不炸;模型那一路的硬拒一个字不变),②③ 恢复 prune:true ——
  死锁与积行两头都收,debt「prune 与确认即入库的架构性冲突」销账。
  quiet 拍仍不撤行(F3),清理落在每次确认工单那一拍。

④ 设计:块① 从 11 列横滑表推翻成电路板工作台 —— 深色板底上两张可就地编辑的
  芯片档案卡(SOIC 封装 SVG + 角色/在库徽 + 类型行 + 双栏主数据格),编辑态整卡
  变表单;三级占位(必填红/建议橘)上卡与参数值表;参数值表所属料号列加角色徽。
  嵌套的角色/在库查表全只读(嵌套行里有 data-col 会被 collect 并成一条,测钉死)。

契约:loop-engine port draftDrop +skipLanded/+landed(纯新增,CONTRACT.md 已记,
api-report 已刷)。测:107 包内(三锚变异验证)+ auto-runner skipLanded(变异验证)
+ 2520 全仓;2.7.0 装库 SQL 核过(档案卡宿主/守卫/胶囊/S1 四笔 prune)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 14:12:10 +08:00
xcsandClaude Fable 5 e2db7cfa01 docs(w05): 视图层重设计入快照 + 销 badgeCss 三处一份的债
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 13:06:10 +08:00
xcsandClaude Fable 5 e18611006b feat(w05): 三张工单件视图层重设计 —— 电路板夜景 hero + 精密检测报告版面,功能绑定逐项保留
用户定:中央工作区不要后台管理端的样子,按 C 端界面重设计,可加主题 SVG 与动画,
只保留功能。三张件换成「精密检测报告」语言:
· 题头深色电路板 hero,三张各一幕 —— 建档台(两颗料的芯片铭牌,由 mine:sub_eval.part
  铺行,不在库虚线描边)/ 对决量尺(原 PN → 候选 PN + 推荐等级徽章)/ 落账席(慢转
  印章环 + 一句话结论);信号点流动、晶粒呼吸、入场浮起,全部 transform/opacity,
  prefers-reduced-motion 全静。
· 正文瓷白报告版面:丝印小标 + 简报卡 + 报告表格;比对光谱(四态分布带 + 刻度)、
  落账三卡(大数)、四态计数丸。验收两件仍是用户定的验收绿 #0aa294,数据工单数据蓝。
· 设计系统收进 views/style.ts;目标件时代的 target-style.ts 退役(报表样式只此一处真源);
  report.ts 里与 badge.ts 重复的 vtok/badgeCss 收归一处(销一笔 debt)。

功能一个不动:可编辑表的 DOM 结构逐字节保留(data-rows/data-edit/data-col/编辑闸/
种子行/主键钥匙/thead 后置/红带),机制规则(序号计数器、jc-row-static 显隐、
gate-bar :has(:empty)、overflow-x)逐字保留并仍被包内测试钉着。

修掉三个自审逮到的洞:kv 占位破折号只认直接子级(嵌套徽标被「—」盖掉)、光谱条里
不渗计数字、加行钮补 base display:none(从前没开编辑就露着)。

104 包内测 + 2516 全仓测绿;两条新锚(主题色/hero 外壳)变异验证各红过一次;
2.6.0 已装库并 SQL 核过(hero/主题色/种子行/vtok/零脚本逐件中)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 13:03:36 +08:00
xcsandClaude Opus 5 1e3e5d40c6 feat(w05): 差异评估结论审核的收口四格可就地改 —— 判定归规则,话归人
用户:「差异评估结论审核这个界面的收口结论下面这 4 个字段也需要可以编辑」

这四格(推荐理由 / 差异摘要 / 替代风险 / 验证要求)原本是**故意**只读的,理由写在源码里:
「让人直接改这几格,等于让他给自己没做的判断盖章」。用户改了这个口径 —— 审核人对结论
有最终发言权。原理由留在注释里备查。

**光把格子打开等于没开**:`RELATION_PROJECTION` 每跑一次就按规则把这四列重写一遍,
而人在件上按保存会触发 quiet 拍、quiet 恰恰跑 project —— 人刚写下的话当场被规则值冲掉,
那是「让你改、改完被冲掉」,比不给改更糟(基座不记「谁写的」,配方分不清一格是人改的
还是自己写的,所以只能从配方这一侧下手)。

改法:S3 换用新的 `RELATION_RECOMPUTE` —— 与 `DIFFERENCE_ITEM_RECOMPUTE` 同一手法,
**只列主键 + recommendation_level**。patch 是稀疏的、DB 侧是 jsonb 浅合并,没列的列原样
留着,人改过的四格因此保得住。**推荐等级仍然重算且不容手改**:它是规则按四态分布算出来
的判据核心(件② 判据表 / 件③ 落账结论 / 出口谓词都读它),「人改四态 → 等级跟着变」
这条链不能断。分工因此是:**判定归规则,话归人**。

代价说在明处,件上那段说明已改口:改完四态,这四格不会自动跟着改口(否则刚写的话就没了)
——「四态改完,记得回头看看这四格还对不对」。界面不撒谎优先于自动。

件上:三句话走 cell(只读份 + 孪生输入),风险只读态摆徽标、编辑态换 select(枚举列不许
直显 low/medium/high),整块一个宿主一道闸,主键钥匙藏在 .rowkey 里但真挂 data-col。
.summary-box 不是表格 —— setOpen 对非表格容器把 .on 落在 data-edit 元素自身,CSS 按
.summary-box.on 写。

测:件上四格可改 + 一个宿主一道闸 + 钥匙在场;S3 重算笔四列一个都不许出现、判定仍在且
与 S2 共用同一条 when 链。四条变异各红一条(S3 退回用 RELATION_PROJECTION / 混进
difference_summary / 混进 risk_level / 判定不再重算)。pnpm gates 退出码 0(2495 测)。
包版本 2.3.0 -> 2.4.0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 11:53:33 +08:00
xcsandClaude Opus 5 e5ca9caaa6 fix(ledger): 草稿层按诞生序读 —— 保存过的行不再跳到列表末尾
用户:「点击保存后,列表里面的元素每次排列顺序都不一致,需要保持顺序的一致性」

病根在读:`select * from ledger.__draft ... order by updated_at`,而 `put` 的 upsert
分支每次都写 `updated_at = now()` —— **人保存了哪几行,哪几行就跳到列表末尾**;
同一批写下的行(配方一次 project 铺 7 行)`updated_at` 又完全相同,并列之间没有次级键,
PostgreSQL 返回什么顺序都算数(更新一行 = 旧版本作废 + 新版本插到堆尾,物理顺序本来
就会变),于是每保存一次屏上的表就重排一次。

加 `seq bigserial`:insert 那一刻取号、upsert 的 update 分支不碰它 —— 记的正是「这一行
是第几个被写下的」,而配方铺行的顺序就是人期望在件上看到的顺序(原 PN 的参数在前、
候选的在后,组内按字典序 —— 配方那么遍历的)。单键、严格全序、没有并列。

**列加在 DRAFT_DDL 里,不写迁移**:这张表由启动时的 DDL 自建(`container.ts` 调
`DRAFT_DDL`),`landed_at` 立的就是这个先例 —— 头一版我没读到那行注释,写了迁移 0036
又撤掉(journal 已回退;开发库那条孤儿记录无害,drizzle 只按 when 比大小)。

测:ledger 集成测试补一条(对真库),同批写下五行 → 顺序原样;改中间那行、再改另外两行
→ 顺序一个字不变;值确实写进去了(不是靠没写成功换来的稳定);新写的行排末尾。
变异验证:读序退回 updated_at,这一条真红(也证明它连的是真库,不是被 skip 掉)。
pnpm gates 退出码 0(2495 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 11:35:03 +08:00
xcsandClaude Opus 5 2265ab7833 fix(desk): 保存那一拍对话区也别出声 —— 「在办事」不亮、say 落工作日志不上对话区
用户:「每次保存的时候右边也会出现 ai 推进中,然后会说一句话,能不能把这个也取消掉」

两处,同一个根:件上按保存会 wake 这一单(「保存即铺格」),runtime 被拨成 running。

① 右边那条办事条:`TaskChat` 的 `working` 只看 runtime === 'running'。它自己的注释
早写着「等人、等外部、已收尾都不算 —— 那时亮『工作中』就是撒谎:环没在转,是在等」——
save-wake 恰恰把「在等人」的单拨成了 running,把这条原则架空了。加 `!awaitingHuman(...)`
是**恢复**那句注释说的语义:有未办结工单 / 未回的卡就是在等人,哪怕 runtime 写着 running。
判断与工作区那一侧同一个函数。

② 那句话:quiet 拍只跑 project 与 say,于是人按几次保存,同一句交代往对话区说几遍。
对话区的纪律是**一轮一句,不是一拍一句**(2026-08-01):只有交出控制权那一拍的交代
摆到人面前;而 quiet 拍压根没交出控制权(人正对着卡填),算不上新一轮。
改:quiet 拍的 say 标 `internal`。压下去不是丢掉 —— `internal` 是两个投影共用的一个旗
(终审 I5/F2):`chat-projection` 据此挡住对话区,`task-log.machinery()` 对 `detail.auto`
开了例外,这一笔在工作日志里照旧看得见(话有插值时值的变化也查得到)。

测:quiet 的 say 标 internal 且工作日志仍在(3 条变异各红一条 —— 退回原样 / 反过来连
非 quiet 也标 / 连 auto 一起去掉;其中「反过来标」红的是既有那条「非 quiet 不标」,
两条互为护栏)。后一半(标了 internal 就进不了对话区)由 chat-projection 既有测钉着,
projectChat 没出模块边界,两半各钉在自己模块里、注释互指。
pnpm gates 退出码 0(2494 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 11:16:09 +08:00
xcsandClaude Opus 5 c8988c912f fix(desk): 有活等着人办就不摆「AI 正在推进这单」—— 保存后那一闪盖掉的是刚重推的件
用户:「在中央工作区修改了内容点击保存后,为什么会出现『AI 正在推进这单』的提示?」

因果链:件上按保存 → afterSave **先重推件、再叫醒任务**(「保存即铺格」,2026-08-29:
人选了芯片类型按保存,配方这才有机会把参数行铺出来;不叫醒格子永远不出现,08-30
走查在这儿卡死过)→ wake 把 runtime 拨成 running → 工作区的 running 判据排在 viewHtml
前面(2026-08-21:正在跑的这一拍要压过上一拍摆下的**陈旧**的件)→ 人刚填完按下保存,
屏上那份刚重推、含着他刚填的值的件被推进场景顶掉几秒。

08-21 那条规则的前提是「viewHtml 是上一次推的,可能陈旧」,而这条路上件比 running
还新,前提不成立;更要紧的是那一拍按「叫了人就等人」的闸(turn-runner,08-27)
**根本不问模型**,而那一屏写着「看材料、写台账、调工具都由它来」——一件没发生,
界面在撒谎。

修:running 那一支加 `&& !holding`。判断收进纯函数 `awaitingHuman`(未办结工单 /
未选决策卡 / 未确认录入卡),与服务端 humanTool.pendingCards 说的是同一句话 ——
**有未答的卡,服务端就不问模型,前端就不说 AI 在推进**,两侧同一条道理的两面。
没有任何卡在等人、AI 真在埋头算时,推进场景照旧摆(2026-08-21 要的正是它)。

6 条新测,四项各做变异验证(去掉工单项 / 决策卡项 / 录入卡项各红一条,恒真红两条 ——
恒真等于把推进场景整个废掉);pnpm gates 退出码 0(2493 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 10:59:33 +08:00
xcsandClaude Opus 5 11e41b79c5 revert(w05): 数据工单退回换版面之前的旧版面(2.3.0),新版面存在 feat/w05-view-swap
用户实测新版面「感觉有点问题」,先退回稳定的旧版面,新版面原样留在分支上随时可切。

退法不是 reset —— 历史与素材都留着:report.ts / target-style.ts / activity.test.ts
三个文件逐字节恢复到 45cec82 的样子(已 diff 核对为零差异),平台侧的探针测试
(view-container-collect-div-rows / -nested / view-render-nested-editable)、目标件 html、
交接件文档全部留在主支 —— 它们钉的是平台行为与素材,不随这次退回作废。

版本号 2.2.2 -> 2.3.0 而不是退回 2.1.9:store.manifest 不传版本时按 installedAt 取
**最近安装**的那一条(不是版本号最大的),库里 2.1.9 那行的 installedAt 比 2.2.2 早,
同版重装又是 no-op —— 只有拿一个新号把旧版面装进去,注册表才真的换回旧版面。

CLAUDE.md 那条战果记录改口标注「已退回」并指向分支,原文留着当索引。
pnpm gates 退出码 0(2487 测;比换版面那版少的 10 条正是随 UI 一起退掉的新版面钉子)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 10:31:59 +08:00
xcsandClaude Opus 5 1373eb4980 docs(w05): 入库换版面的目标件 —— target-style.ts 头注与机械抽取的 diff 核对都指着它
CI / ci (push) Failing after 10s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 21:27:10 +08:00
xcsandClaude Opus 5 f0789a0f13 docs(w05): 换版面收口 —— 终审 Minor(describe 头注漂移)修正 + CLAUDE.md 记战果
终审评审 0 Critical / 0 Important / 1 Minor:件① 的 describe 头注还描述换版面前的
「四块竖排」,断言早已测新结构 —— 头注改写为两轨道 / details 面板 / 瓷贴的现状。
CLAUDE.md 现状快照补一条:换版面三个机制账(宿主闭合标签截刀 / 行内 class-of 只认行
作用域 / data-by 回落真咬人)与全流程走查战果。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 21:26:36 +08:00
xcsandClaude Opus 5 18de7653bd fix(w05): 走查逮到的三处 —— 漏掉的 profile 闸 · 圆点投影搬到行外包裹层 · 类型查名加诚实守卫
① profile 的编辑闸与缺项红带在第一版 body 里整个漏掉:宿主在而闸不在,轨道打不开编辑,
XL 的必填永远补不上(Task 7 那颗「按不下去的确认钮」同款)。「四道闸」旧断言只查
data-edit 宿主没查 [data-edit-for] 闸,逮不到 —— 断言补强为闸 + 开钮都在。

② 行内的 data-class-of 只认行作用域(renderRows 末段 renderTemplate(…, one) 拿的是
行对象),作用域级读数在行内投不出 —— 圆点上的 data-class-of 是死属性,永远不亮。
投影搬到行外的三层 .pj 包裹层,样式表按后代选择器点亮;代价是两条轨道的圆点一起亮
(基座数本来就是表级的,分到每颗料头上的数基座没有,不编)。

③ data-by 在行缺键时落回外层同名字段(终审 I3 已记档的平台缺陷):XL 没选类型、
它的 sub_eval.part 行与 parts.profile 草稿行都没有 chip_type_id 键,查名 pill 借外层
摊平的 chip_type_id 摆出「DC-DC 降压稳压器」—— 档案卡与字段行都在替人表态。
守卫认 selectCurrent 按这一行自己的值写下的 option[selected]:行里没真选上,
名字不摆、摆「(未选择)」;判断全在样式表。

三条新钉各做变异(恰红一条、复原绿);pnpm gates 退出码 0(2497 测)。2.2.1 -> 2.2.2。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 20:51:04 +08:00
xcsandClaude Opus 5 64fe4b43c9 fix(w05): data-rows 宿主换 <article> —— div 宿主被行模版里第一个 </div> 截断,档案卡当场串行
浏览器走查逮到的真事故:renderRows 的 ROWS 正则按宿主自己的闭合标签非贪婪截取行模版,
<div class="lanes"> 的行模版在 lab 那格的 </div> 处被截半,第二行的展开被浏览器塞进
第一行没闭合的标签里 —— 屏上「候选内部料号 / LM2596S-5.0 / 不在库」三样各来自不同的行。
A2 立的「行容器用行内不会再出现的标签」说的正是宿主(它自己用的就是 <article> 包 <section>),
我在计划里引了这条却把它按在了行标签上。.lanes / .pvgrid 两个宿主同病同修。

防再犯钉:照 ROWS 正则同款截取行模版,断言截出来的内容标签配平(截断必然留下没闭合的
标签)—— 第一版写成「不含宿主闭合标签」是空话:非贪婪截取本来就停在闭合标签之前,
那条断言永远不会红;换配平判法后,两个宿主各自退回 div 的变异都真红、复原绿。

pnpm gates 退出码 0(2496 测);包版本 2.2.0 -> 2.2.1(2.2.0 已装库,安装恒新增)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 20:39:42 +08:00
xcsandClaude Opus 5 0f40bae0d1 feat(w05): input-supplement 照新目标件换版面 —— 两条轨道 + details 面板 + 参数瓷贴
照 docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html 重排数据工单那一屏,
绑定面与功能一个不动(四张台账、四道闸、两处加行、终审 C1 的 disabled type_id、
主键钥匙全部原样带走)。用户定的三件事:每颗料一条轨道(两条并列)/ 新增类型面板
每行四格(parts.type_param 那张表本就是,搬进 details 面板,独立区退役)/ 不动平台
每步骤卡自带一道闸。

P08_CSS 整段换成新目标件的机械抽取(嵌进 TS 后与源文件 style 区间 diff 零差异);
旧目标件自带的孪生件显隐规则(.jc-vcell/.jc-cell/加删行)改由 BIND_P08 承载,
focus 光环换成新 :root 里真存在的 --data-wash(旧名 --brand-wash 会被浏览器整条丢弃)。

步骤③ 不按轨道分两栏是机制不是偏好:bodyOf 单 querySelector、rowOf 认宿主直接
子元素 —— 嵌套形状会把一颗料的所有参数格并成一条记录(后写的覆盖先写的、静默),
探针用真实 renderView+collect 钉死(view-container-collect-div-rows.test.ts,前一提交)。
瓷贴单块平铺,主键钥匙收进 .rowkey(display:none 但真挂 data-col)。

目标件脚本驱动的状态全部无脚本翻译:保存即铺格顶掉「选类型即时加载」、details 顶掉
.newtype.show、步骤圆点按基座数点亮(_missing 的 v-0 / distinct 的 v-1)、类型不一致
护栏红带按 sub_eval_part_distinct_chip_type_id 的 v-2 档现身;gateprog 进度条如实退掉
(分母不是基座现有的数,编一个就是界面撒谎)。

活动自测 62 -> 72(8 条新钉逐条变异验证:恰红一条、复原即绿);pnpm gates 退出码 0
(2495 测)。包版本 2.1.9 -> 2.2.0(安装恒新增)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 20:29:41 +08:00
xcsandClaude Opus 5 e38631224a test(w05): 数据工单嵌套 data-rows 探针 —— 钉死 collect() 的并行行为,平坦形状是唯一能用的路
W05「核心参数值」想做成外层按候选料分组(<section>)、内层铺参数瓷贴的嵌套 data-rows 版面。
用真实渲染管线(renderView)产出 DOM 后喂给真实 collect() 实测:rowOf() 爬到 host 的直接
子元素时只能爬到候选料的 <section>(瓷贴与 section 之间还隔着内层 data-rows 留下的一层
<div>),于是一颗料的多个参数瓷贴被并成一条记录、后写的列覆盖先写的,悄悄丢数据 ——
证实预读判断(嵌套 = 并行),用两条断言(记录数 2 非 6、input_voltage/output_voltage 两条
参数在输出里完全消失)钉死,并用四条变异验证(候选数变 3→段数跟 / 摘一整段→记录数跟 /
摘主键 data-col→字段消失)确认断言真的在读结构。

同时钉平坦退路形状(瓷贴直接是 data-edit 宿主的子元素,data-rows 不分料铺):collect()
按瓷贴数返回记录、互不覆盖,行容器换成 <div>/<li> 均成立(现有测试全是 <tr>,没人钉过
非表格行容器)。

renderView(apps/api,tsconfig.node.json)与 collect()(packages/ui-kit,tsconfig.web.json:
jsx react-jsx + DOM lib)不能进同一个测试文件——depcruise 放行,但 typecheck 真红
(TS6142: ViewContainer.tsx 用了 JSX,tsconfig.node.json 没设 --jsx),两侧拆开、
中间用同一段渲染输出的 HTML 字符串接力(两处注释互相点名,防副本漂移)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 20:22:38 +08:00
xcsandClaude Opus 5 4076621e04 docs: 交接件 —— input-supplement 换版面的设计与前提(为上下文压缩而写)
用户已批准的 bounded 改动:把 S1 数据工单那一屏的视图层换成
docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html 的版面,绑定与功能不动。

用户已定三件事(压缩后不必再问):两条并列轨道 · 新增类型面板每行扩四格且去掉独立字段表 ·
不动平台每步骤卡一道闸。

文档里落了压缩后会用到的全部事实:目标件结构、现有件四个片段的行号与绑定、
P08_CSS 的位置、ViewContainer 的 editable/bodyOf/addRow 机制、gateLedgers 四张表、
必须留着的终审 C1 修复(那个 disabled 的 data-col=type_id)、
以及一条要先验证再往下写的风险(步骤③ 两组瓷贴共用一个宿主时 cellsOf/rowOf 能否正确归行 ——
bodyOf 只找得到一个宿主,认错会把两颗料的参数值混成一行)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 19:32:15 +08:00
xcsandClaude Opus 5 45cec82984 fix(w05): 终审两条 Critical + 地基缺陷的另一半 + 文档与欠账收口
**C1 新建芯片类型会改写既有字典行(共享主数据被静默污染)**
链条逐行复核成立:件上种子行漏挂 data-col → barred 与 isSeq 双保护同时失效
(isSeq 那条是因为 Task 7b 把 type_id 从 integer 改成了 text)→ 落进「全表一致」,
而 H35 的自投影恰好保证这张表只有一行 → 人新建的类型被认成既有 type_id=1 →
确认时随范围放行把既有字典行改名并落进正式表,屏上只说「改了 1 行」。
三条各自成立的裁定叠出来的静默污染。
修法:种子行改成 disabled 的真实 <input data-col=type_id>(desk.ts 零改动),
格子里摆「由系统生成」——不能一声不吭地拿掉,那样人会以为这张档少了一格。
测试不止断言结果:另加两条用**真实 resolveRowKey** 重放病灶/修法两种 cells 形状,
直接证明因果机制。平台根治(全表一致应排除 auto 列)记 debt。

**C2 两个启动参数都选填 → 留空则整单永久无解**
gate_reason 说「在下面把两格补上」,而块① 没有加一行、也不在 gateLedgers ——
一句指向空气的指路。改为必填;第一档改口为唯一真无解的那条路径(两格填了同一个号)。

**I1 地基缺陷只修了一半:recipe.let 同样按对象键序求值**
与 fields 是一模一样的假设,同样住 jsonb 列、同样被 PG 重排;今天不炸只因 W05 那五条
互不引用,而 ADR-014 是给后续所有活动用的。新增 orderLets/letReferences
(把 {in:X} 视为依赖 —— 语义与 fields 不同,故单独实现),auto-runner 与静态审同步接入。

**I2 审核卡按「重做」照样收步推进** —— 坐实为真实平台缺陷(「卡答过了就收步」快道不看裁决),
并证明活动侧加选项救不了、chosenGuidance 那条也救不了(那是喂模型的提示词,
而这条快道存在的目的就是跳过模型)。按裁定只留复现测试,平台未改。

**I4** 同一堵墙撞第二次即发兜底执行工单交人 + 立监听(头一次软化留痕),
不再空转到 exhausted 而人无处可改。

debt +4(C1 根治 / modules→app / thead-after-tbody / data-by 落回外层),
并补写此前被两处引用却从不存在的「refersTo 传递闭包」条目。
文档:ADR-014:201「按声明顺序求值」改对(照它写配方的人会重犯本轮的地基缺陷)、
陈旧注释四处、spec 四处分叉逐条判断处置、端到端测试记录标为历史、
CLAUDE.md 纠正「新建类型端到端通了」那句(它没通过真人路)并更新版本与文档表。

包版本 2.1.9(已装,SQL 直查库内 version/required/gate_reason/视图 HTML 四项核实)。
pnpm gates 退出码 0(2476 通过 / 34 跳过);E04 92/92、I01 55/55 无回归。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 16:09:18 +08:00
xcsandClaude Opus 5 9f5b1c99d3 fix(w05): S3 补一笔只重算派生列 —— 人改了四态,结论才跟得上
浏览器走查实测:件上承诺「你改动下表里任何一条四态,这五项当场重算」,
而把使能阈值由 mismatch 改成 match 保存后,同一行的 judgement_class 仍是
hard_mismatch、risk_level 仍 high —— 行内自相矛盾,结论纹丝不动
(仍「不推荐 / 高风险 / 1 项硬约束不符」)。

断链在:S3 复用的 RELATION_PROJECTION 算结论读的是 judgement_class 的 tally,
而 judgement_class 由 difference_status 在差异明细那一笔的 when 链推出来 ——
那一笔只在 S2。人改的 difference_status 没有任何东西把它转成 judgement_class。

当初不把那一笔放进 S3 是有真理由的:它的 difference_status 走 lookup:'result:items',
而 result: 只在 analyze 那一拍存在,在 S3 重跑会把人刚改的值抹成空。

修法:加 DIFFERENCE_ITEM_RECOMPUTE 作 S3 的第一笔 —— each(mine:…) 不 prune
(避开「已落地行不许 prune」),fields 只有两个主键列 + JUDGEMENT_DERIVED_FIELDS
(judgement_class / risk_level),**一列都不多写**,稀疏 patch 绝不碰
difference_status 与人可改的叙述列;排在 RELATION_PROJECTION 之前。
when 链提成 JUDGEMENT_DERIVED_FIELDS 一处真源,S2 与 S3 spread 同一个,
新测试用 toBe 断言引用相等(不是形状相同)。

**旧测试为什么漏掉**:w05-s3-review-recompute.test.ts 的 HUMAN_EDIT mock 在同一个
patch 里同时写了 difference_status 和 judgement_class —— **把系统本该做的翻译自己
先算好了**,那座缺失的桥就此被掩盖。现改为只写人真能碰的列(difference_status +
evidence_note),并加修前/修后成对断言。

变异验证两路都真红:①拆掉配方里那一笔 → 2/3 红,复原 → 3/3 绿;
②git stash 真 case-model.ts → steps.test.ts 5 条结构测试红,pop → 38/38 绿。

包版本 2.1.8(已装,SQL 核过)。pnpm gates 退出码 0(2454 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 13:27:33 +08:00
xcsandClaude Opus 5 764dab342d fix(w05): 三份件的机械抽样式补齐缺失 class —— 落账件同款会炸,提前拦下
浏览器走查实测:差异评估结论审核件里三个 <svg class="zic"> 撑成 946×946,
整份文档拉到 4625px 高,正文全被挤走 —— 人打开只见灰色巨块,没法逐条核四态。

根因:.zic{width:14px;height:14px} 只在 P08_CSS(数据工单那一段),而
difference-review 用的是 ACC1_CSS。ACC1_CSS / ACC2_CSS 是从目标件机械抽出来的,
report.ts:538 那句既有注释早已承认漏过一批(.jc-vcell/.jc-cell/…),补了几个,
这几个漏了。

按「一次查干净」逐个核三份件用到的每个 class,补齐:
· .zic —— ACC1_CSS **与 ACC2_CSS**(后者即 landing-confirm,同款会炸,走查下一步就会撞上)
· .note .done / .note .need —— ACC1_CSS 与 ACC2_CSS
· .kv-box —— BIND_LANDING
· .risk-badge 缺基座形状(只有配色没有胶囊盒子)—— 人工核对徽标家族一致性时发现,机械扫描漏网

新增自测:三份件用到的 class 必须都能在它自己那段 CSS 里找到定义 ——
将来谁再加一个没定义的 class 就变红。变异验证:删掉 .zic 那条真红,补回真绿。

另:嵌套 data-by 的「只读态显示了并不存在的值」已坐实为渲染器侧机制
(递归合并时行内没有该字段会落回外层同名字段),加 it.fails 钉住,平台修法另议。

包版本 2.1.7。pnpm gates 退出码 0(164 文件 / 2450 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 12:51:14 +08:00
xcsandClaude Opus 5 9e2f7387b2 fix(loop-engine): 有卡在等人交回时配方写下新行,收拍前重推画面 —— 短路收拍从不推帧
浏览器走查实测(ACT-WORK-05 数据工单):人给候选料选好芯片类型、按保存,S1_inputs 的
配方在 quiet 模式下铺出 7 行新参数骨架、红带同步点名要填这 7 格,而 `runTurn` 的
「叫了人就等人」分支(spec §5.4)写完就直接 `return { status: 'awaiting' }` —— 真正的
「推一帧」逻辑住在同一函数里更靠后的位置(action 循环之后 / `issuedCardId` 分支),
这条早退根本到不了那里。人刷新页面看到的仍是保存前那份服务端快照(件是渲好推来的,
重放不重渲),被红带点名要填的格子屏上根本不存在,走查到此卡死。

修法:quiet 分支里捕获 `autoRunner.run()` 的返回值,若这一拍真写下或撤掉过行
(`wrote>0 || dropped>0`)且当前在办的这一步声明了 `view`,收拍前调用同一个
`showView` 重推 —— 与 `issuedCardId` 分支「声明了画面才推」同一条纪律;quiet 模式
不迁移计划项状态,`runtime` 用的还是拍首那份,不会重犯 2026-08-23 那次「推错哪一步
画面」的错(这次没跨步,只是把同一步最新的草稿重渲一遍)。

回归测试用真 `createTurnRunner`/真 `createAutoRunner`(不手抄收拍逻辑),给共享 harness
加 `pushedScopes` 记录每次推帧的 scope,断言画面里真带着刚写的那一行(内容比对,不是
计数)。变异验证:git stash 掉修复后该测试从 157/157 变 156 passed + 1 failed
(`expected [] to deeply equal ['input-supplement']`),stash pop 后回到 157/157。

`progress.md` H49 记录过一个高度相似的怀疑:e2e 反复撞见的「答完卡不推进」(案例4×3、
案例3×1,advancing_until 全程为空、零报错)——两者终态日志的最后一行逐字相同
(`等人交回:数据工单… —— 这一拍不动`,task_memory 定格在同一条、之后再无记录)。
候选缺库类案例的两阶段表单(选类型→保存→配方铺出新参数格→还要再填)恰好依赖画面
重推来发现第二轮要填什么;这条早退不推帧能完整解释「零报错 + 永久卡死」。未另跑
e2e 坐实(任务明令不跑,浏览器里留着一单要用来验证),记为高置信度推断。

pnpm gates 退出码 0(2448 passed | 34 skipped,基线 2447 + 本次新增 1 条)。
E04(crm-relationship-coverage)92/92、I01(brand-content-toolkit)55/55,均 exit 0。
manifest.ts 里那处与本次无关的未提交版本号改动(2.1.5→2.1.6)未动、未纳入本提交。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 11:49:27 +08:00
xcsandClaude Opus 5 8586f7dac8 docs(w05): 重写收口 —— 头注核正、字段说明按现状重写、规范补四条 auto 纪律、CLAUDE.md 记战果
Task 12 的非走查部分(浏览器走查卡在登录、归人工,不在本次范围)。

① index.ts 头注仍写着旧的「三条技能 + 五张模版」,重写后是 0 技能 3 张件,核正
(Ruling H25);包内其余「三条技能/五张/两枚工具/九步」等提法逐处核过,均为带日期的
「从…改成…」历史叙述,不是失实断言,未动。

② docs/数据域字段说明-W05.md 整篇重写(不是打补丁)。旧文档写的是重写前的十张表
(customer.profile/parts.document/demand.request 与 sub_eval 三张旧单据,本活动这次
重写已不再声明),现按 manifest.ts 的 declarations.ledger 逐表核对重写:9 张表、66 列、
20 人填列。写入者词表另定一套(人(件上)/AI(S2 唯一一次 analyze)/配方(project 算出来
的)/基座)——E04/I01 那套多轮 agent 编排的词表在这里说不清楚。逐个 data-edit 核对过
report.ts 才确认写入者归属:S3 的四态等列改成人在件上直接编辑(不再是「人写意见→AI 改」),
sub_eval.part/case、substitution.relation、validation.record 四张表没有任何人工编辑
入口、全部配方算。顺手核了 seed-mvp-data.ts 的基线物理列,注了每张表里这次重写不再
声明的旧列(旧九步版本留下的痕迹),并记下 type_id 这条身份链路是本仓唯一一处
integer→text 加宽声明。

③ docs/活动开发规范.md §4.6 补四条纪律(六条→十条):jsonb 会重排 fields 键序、字段
求值改按依赖拓扑排序不能拿书写顺序当依赖顺序;跨笔顺序仍要自己排(一笔读的是另一笔
写出来的数就要排后面);两笔方向相反的依赖拆两笔写中间夹子表;gateLedgers 同时管
「闸要求非空」与「确认时并进正式表」两件事,漏想一半会悬空引用或永久卡死。§6.5 补第 9
条「测试要能咬住」(镜像测试与读真文件测试证明的是两件事;变异测试本身可能是空的;
能 import 真东西就别手抄)。§7 补两行排障(reset-runs 前先捞证据;dev 进程可能在跑
几小时前的代码,连 tsup --watch 监视进程本身都可能已死)。八条内容逐条去任务报告核过
原始事故与提交号,没有凭印象转述。

④ CLAUDE.md 现状快照追加一条:四步全 auto、两张类型字典、三张件、退役清单;e2e 三例
released 被拒 0(候选缺库/都在库/原PN缺库,模型调用恰 1 次);类型不一致那例撞上已记
debt.md 的 prune 死锁,两颗都缺库那例卡在「答完卡不推进」、真因未定位如实记录;
judgement_class 7/7 逐行核对通过;jsonb 键序地基缺陷的发现与修法(拓扑排序,6636abf)。
顺手核正文档地图里 W05 那一行的旧字段数(十张/110列→九张/66列,与②同步)。

pnpm gates 退出码 0(2447 passed | 34 skipped),与 Task 11 最后一轮记录一致,无回归。
未碰 manifest.ts 里那处与本任务无关的未提交版本号改动(2.1.5→2.1.6,疑似上一个卡在
登录的浏览器走查会话留下)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 11:22:56 +08:00
xcsandClaude Opus 5 d420284887 feat(w05): S2 关系表拆两笔写、S1 自投影类型字典、e2e 跨案例隔离 —— 真跑逼出来的三处
① S2:substitution.relation 拆成两笔同主键覆盖写,中间夹 difference_item ——
   difference_item.substitution_relation_id 声明了 refersTo(要求父行已存在),
   而 relation 的等级/风险/摘要要读 difference_item 的 tally,两张表互相要对方先写。

② S1:总把选中的那一行 chip_type / type_param 原样投一遍(幂等),
   于是这一单的草稿层永不为空 —— parts.chip_type 得以放回 gateLedgers,
   新建的类型随「数据工单确认即入库」一起落进正式表,不再留悬空引用。
   (此前把它删出名单只顾了闸、没顾落地:引用它的表落了地而它自己没落。)

③ e2e:案例 0 之后做跨案例隔离(withDb + isolationCleanup,不论成败都跑、
   失败只告警不拖垮整套)—— LAND_MASTER=1 之下案例 0 为满足 distinct=1 必须把
   共享的原 PN 转挂新类型,那一改会落进主数据,案例 1 共用同一颗料就看到假的类型不一致。
   断言 expectRevised 改名 expectFinalValue:「点名驳回归属」那条对 W05 不适用
   (四步 guide 全空、唯一的 agent 调用发生在意见之前,没有轮次会读到那条观察)。

包版本 2.1.5。pnpm gates 退出码 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 07:06:52 +08:00
xcsandClaude Opus 5 6636abfc39 fix(auto): 配方字段按依赖拓扑求值 —— jsonb 会重排对象键,声明顺序靠不住
真跑 e2e 逼出来的地基缺陷。活动清单存在 sys.activity_package.manifest 这个
**jsonb 列**里,而 PG 按「先短后长、同长字典序」重排所有嵌套对象的键。
auto-runner 原先逐字段**按声明顺序**求值、后面的字段靠 ctx.computed 看前面的值 ——
顺序一乱,依赖就断,而且**一声不响**。

W05 实证:judgement_class(15B)被排到它依赖的 difference_status(17B)前面,
求值时后者恒 undefined、when 三级全落空,摔进 constraint_type 兜底档 ——
7 行差异结论全判错,与实际状态无关;而 difference_status 走
lookup:'result:items' 不依赖字段序,所以它是对的。「一个对一个全错」由此而来。
隔离测试复现不出来,因为它直接读 dist/manifest.json,从不经 DB 往返。

修法:字段求值改为**依赖拓扑排序**(Kahn),不用迭代到不动点 ——
后者的数值收敛判据会漏掉「A/B 互相 when 为假、恰好在错误状态下稳定」这种
结构有环却数值收敛的配方;拓扑是结构性判定,不会假阴性。
通用图算法 topoOrder 提进 @utils(契约新增,非破坏);
AutoExpr→依赖名提取因 L4 不能 import L5,在 loop-engine 与 activity-registry
各留一份并互相指路。真正的环由装包静态审当场拒装 —— 不兜底,兜底正是这次出事的形态。

变异验证:git stash 只撤 auto-runner.ts 那一处,两条新测试当场变红,
数值与专查报告的 SQL 转录逐字吻合(使能阈值/match 被误判 hard_mismatch/medium);
stash pop 后 37/37 绿。E04 92/92、I01 55/55 全绿;全仓 2447 测通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 07:05:48 +08:00
xcsandClaude Opus 5 67fa226380 fix(w05-e2e): 登录优先级倒过来(服务账号优先)+ 候选缺库案例改两阶段填表 —— 五案例仍未跑成
Round 2 逼出的两处修:
①`login()`/`DIRECT_BEARER` 从「旁路无条件优先」改成「服务账号三变量全齐才是正路,
JCAI_E2E_BEARER 只在缺席时兜底」——实测踩过:.stable/env 留着一枚已过期的真人 token
时,脚本会悄悄绕开新配好的服务账号,五案例全部倒在起单前的 401(未派发,SQL 核过
0 个 testbed 任务被创建)。
②案例 0(候选缺库·新建类型)随 Task 7b 的 parts.chip_type.type_id 从 integer 改
text/auto 重写成两阶段 customFill:先铸自增 type_id、读回来再引用它写 type_param/
profile/parameter,与真人在浏览器里「先建类型保存再选它」同一个顺序。

装包已核实:part-substitution-assessment@2.1.1,六列 ALTER 到 text 全部成功(种子
数据完好)。跑前清理过一批与本次改动无关的旧模型(九步版,08-27)测试残渣
(XL2596S-5.0 在 parts__profile 里的假数据 + 连带 78 行)。

Step 4(五案例)仍未跑成——login() 修好之后卡在新的一处:e2e 服务账号
service-account-task-dispatcher 只有 workbench 端,没有 design 端(试跑起单
挂在设计端),ends=["workbench"],token 里没有 admin 角色。三轮试跑全部倒在
login() 里,没有一个任务被创建。需要人给这个服务账号加能开出 design 端的
realm 角色。

pnpm gates 退出码 0(2422 passed | 34 skipped)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 01:22:19 +08:00
xcsandClaude Opus 5 832c662fdb fix(w05): Task 7b 评审 fix round 2 —— 6 条 DOM 测试是空的,bodyOf/addRow 加 export 后补真测试
复审用变异逮到:把 ViewContainer.tsx:305 的 tr:last-of-type 换成明摆着错的 tr:first-of-type
重跑 view-render-addrow-thead.test.ts 新增的 6 条,6/6 全绿零反应。根因:那 6 条只 import 了
一份手抄的选择器复刻(findAddRowTarget),与真实实现之间没有任何绑定,ViewContainer.tsx 换掉
选择器它不会跟着变 —— 全仓当时没有任何测试真的执行过 bodyOf/addRow,对这两个函数的未来回归
零防护。

「不能 export/import」这条前提是错的:packages-are-leaves 只禁 packages→apps,没有任何规则
禁 apps→packages;bodyOf/addRow 只是没加 export——加 export 是零行为改动。本轮第三次踩同一条
(Task 4 读数函数、Task 6 let 镜像,现在是选择器):禁令属实,但抄多了,能 import 的都该 import。

①ViewContainer.tsx:165/303 给 bodyOf/addRow 加 export,函数体一字未改。
②新增 packages/ui-kit/__tests__/view-container-addrow.test.ts(5 条),照 view-container-
collect.test.ts 的写法直接 import 真实函数,合成最小 fixture(不碰任何活动 HTML)。写的时候
自己踩过两处:addRow 的 appendChild 把新行接到 <table> 下面不是插回原 tbody/thead(断言要查
内容不能查落点);tr:last-of-type 与 tr:first-of-type 在单行行组里永远等价(补一组两行的
动态 tbody + data-marker 才让两个选择器真正分道扬镳)。变异验证:选择器换错后 1/5 真红
(row-2 变 row-1),复原验绿。

③两处测试的头注写清各守各的:新测试守机制(addRow/bodyOf 面对通用形状的真实行为),原 6 条
守 W05 这两张表此刻的具体 renderView() 输出形状是对的(选择器仍是镜像,但选择器本身的正确性
已交给新测试)——头注同时改正了此前「不能 import 是 depcruise 拦着」的错误说法,真实原因是
ViewContainer.tsx 含 JSX、需要 tsconfig.web.json 的 jsx 选项,apps/api 只受 tsconfig.node.json
管,类型系统这一关过不去。

pnpm gates 退出码 0(2422 passed | 34 skipped);E04/I01 包内测试 92/92、55/55 全绿,零回归。
报告同批刷新了上一轮贴错版本的失败输出证据(用当前最终代码重新跑过变异)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 01:00:58 +08:00
xcsandClaude Opus 5 57870329e6 fix(w05): Task 7b 评审 Critical —— data-edit 挂外层 table 后「加一行」克隆到表头,不是数据行
外层 data-edit 是为了让 collect() 同时收到静态种子行与动态行,副作用是 ViewContainer.bodyOf()
的单个 querySelector 从此解析到 <table> 本身,addRow() 接着在这个 host 里找 tr:last-of-type ——
CSS :last-of-type 按各自父元素判定,<thead> 里孤零零的那个 <tr> 在它自己的父元素里必然「最后」,
若 <thead> 排在数据行前面就会被文档序优先命中:克隆出来的是四个 <th> 表头文字、零个
input[data-col],还被 appendChild 到 <table> 底下、不在任何 <tbody> 里。parts.type_param 那张
更要紧——它的加行功能在这次改动之前本来是好的(data-edit 只挂 tbody 时,querySelectorAll 的
搜索范围本就不含 thead),外层 data-edit 一挂就成了真回归。

修法(不改 ViewContainer.tsx/view-render.ts):把两张表的 <thead> 挪到两个 <tbody> 之后。CSS 的
table-header-group 排版规则视觉上永远排最前、不看它在 DOM 里排第几(与 HTML5 允许 tfoot 写在
thead 之后、tbody 之前同一条原理)——呈现不变,而 querySelector/:last-of-type 认的是文档序,
thead 排最后,选择器就再也碰不到表头行。

补 6 条真 DOM 测试(apps/api/.../view-render-addrow-thead.test.ts):真实 renderView() 输出
在 jsdom 里挂起来,逐行复刻 bodyOf()/addRow() 的真实选择器链(它们没 export,双向 depcruise
规则也拦着直接 import),断言克隆样本落在 tbody 里、含 N 个具名 input[data-col],不是表头。
apps/api 只受 tsconfig.node.json 管(零 DOM lib),packages/ui-kit 又不许反向 import apps/*
(packages-are-leaves)——用 TS 标准的 per-file `/// <reference lib="dom" />` 只给这一份文件
挂 DOM 类型,不改 tsconfig 本身。activity.test.ts 另补一条字符串级结构安全网(thead 确实排在
两个 tbody 之后),把源码与镜像测试的假设锁在一起。变异验证两段输出(拿掉修复→3/6 真红,
含 report.ts 真实源码单独验证一次红/绿→改回来全绿)贴在报告里。

Important 两条:①两个平台坑(resolveRowKey 续号步骤不认 auto、coerceToColumn 削前导零)
记入 docs/debt.md,写清仍未修、只是被本活动的声明层绕开;②manifest.ts/activity.test.ts 里
「台账是 JSONB 不存在物理列类型」这句错的论据删掉——master/record 是真实物理列(bigint),
只有草稿层是 JSONB,安全的真正原因是 schema-merge.ts 的单调加宽 lattice 把 integer→text
判定成合法加宽,widen-column 生成的 ALTER TYPE text USING col::text 沿 PG 转型没有失败路径。

pnpm gates 退出码 0(2417 passed | 34 skipped);E04/I01 包内测试 92/92、55/55 全绿。
packageVersion 2.1.0 → 2.1.1。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 00:23:47 +08:00
xcsandClaude Opus 5 d9bcce9516 feat(w05): Task 7b —— 件① 补「新建芯片类型」,零平台改动
类型下拉里没有合适的,从前只有一句 guide 文字,没有可落笔的地方(parts.chip_type 全无
编辑面;parts.type_param 有个「加一行」但零行时 ViewContainer.addRow 找不到 tr:last-of-type
可克隆,按钮到不了岸)。

查证 Step 1 的坎(autoKeys 跨行自增键联动通不通)后确认:同一次保存请求内没有机制把父行
刚铸出来的自增键传给同批的子行,这条联动本身不需要通 —— 改成两步流程绕开(新建类型先保存,
type_id 铸出来后立刻出现在 data-options 现读的下拉里;定义核心参数字段时按类型名称选,
不必照抄编号)。

深挖过程中揪出两个从未被触发过的平台坑,根因都是 parts.chip_type.type_id 是本仓唯一
「auto:true 又是 integer」的主键:① resolveRowKey 的「③ 续号」步骤只认 isSeq(declared
type)不认 auto,会抢在 writeEditedRows 的 autoKeys() 前面把新行按草稿行数续号,撞上种子
数据 type_id=1/2(把「DC-DC 降压稳压器」悄悄改名);② autoKeys() 补零的字符串('03')经
引用列的 coerceToColumn(_,'integer') 会被 Number() 削掉前导零,refersTo/data-by 的字符串
比对全部失配。两处坑统一改声明(type_id 自身 + 五处引用列共 6 处,integer→text)一次性
绕开,desk.ts/ViewContainer.tsx/view-render.ts 零改动。

实现:parts.chip_type 新增可编辑表(动态行 + 一条不受 data-rows 管辖的静态种子行,
data-edit 两处写法保证 collect()/.on 切换与 renderRows 的 editable 检测都够得着);
parts.type_param 的 type_id 列从裸文本输入换成下拉(选项现读 parts.chip_type);两张表
各配一条静态种子行解决零行加不出第一行的问题。9 条新测试 + 3 条既有断言更新,变异验证
逐条附证据(拿掉种子行 / 拿掉 text 声明 / 拿掉下拉分别验证目标测试变红,过程中发现并修正
一条子串包含式断言测不出「只剩一份」的退化)。packageVersion 2.0.0→2.1.0。

pnpm gates 退出码 0(2410 passed | 34 skipped);E04/I01 包内测试同批全绿(92/92、55/55)—
本任务未改共享面,理论上不需要专门回归,仍按纪律核过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 23:19:55 +08:00
xcsandClaude Opus 5 a408542bed feat(w05): 五条样例与 e2e 套件就位,包已装库并经 SQL 核对 —— 五案例尚未跑
Task 11 的 Step 1-3。装包结果用 SQL 核过库里那一行(不看脚本的话):
part-substitution-assessment@2.0.0 · enabled · steps 恰四步
[S1_inputs, S2_assess, S3_review, T_release] · 零处残留已退役的工具名 ·
declarations.tools=[] · 五条样例名与设计一致。

**Step 4(五案例 e2e)没跑,故本提交不声称任何跑通结果。**
卡在登录:KEYCLOAK_E2E_CLIENT_SECRET 与 KEYCLOAK_PROVISIONING_CLIENT_SECRET
指纹完全相同(已核),e2e 服务账号的 secret 从来没被真正配过,取 token 恒 401。
另一条路 JCAI_E2E_BEARER 解出来是真人的 token 且已过期 —— 试跑绝不派到用户
工作台,政策上不能用,技术上也用不了。

pnpm gates 退出码 0(2407 通过 / 34 跳过)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 22:28:42 +08:00
xcsandClaude Opus 5 882f22a01e docs(w05): index.ts 头注失实转给 Task 12 收口(Ruling H25)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 21:41:33 +08:00
xcsandClaude Opus 5 716f17ee2a 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
2026-08-29 21:20:56 +08:00
xcsandClaude Opus 5 77df79d6fb test(w05): 「全只读」连编辑闸的外壳一起说死 —— data-edit= 这个子串照不到它们
2026-08-29 评审自拟变异逮到的盲点:editGate()/addrow() 产出的是 data-edit-open= /
-cancel= / -save= / -for= / -add= / -del=,edit 后面跟的是连字符,永远凑不出
data-edit= 这个连续子串。实测往 landing-confirm 插一个孤立的
<button data-edit-open=…>,原有三条断言原样全绿。

眼下没触发(这份件零处调用那两个 helper),但光有闸没有 data-col 孪生格虽然写不进
数据,屏上会冒出一枚按不出结果的「编辑」钮 —— 那是界面撒谎。

变异验证:注入后两条断言都真红;复原后 report.ts 与 HEAD 逐字节一致,
包内 59 passed、pnpm gates 退出码 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 21:08:20 +08:00
xcsandClaude Opus 5 4bd1a04511 feat(w05): 件③ 落账确认与看板重绑 —— 签之前只核对,硬约束不符逐条点名
Task 9: 新增 landing-confirm(T_release 的终局验收工单,全只读,答「这一
签下去发生什么」——落账去向/结论卡/四态分布/硬约束逐条点名/待验项/发口),
三张件此刻齐了;activity-ledger 看板的 data 声明与 HTML 一并重绑到新九
张表(P-01/P-10 两域随重写撤下),解封 3 条等待三张件齐的 skip。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 20:53:23 +08:00
xcsandClaude Opus 5 04675e814d test(w05): 件② 补八条显式只读断言 —— risk_level 撞车骗过了泛化规则
评审变异实测三次逮到两个活洞:original_value 加一个 data-col 就能在验收
工单上篡改基准事实,63 条既有断言零告警;risk_level 同时是
substitution.relation 与 substitution.difference_item 两张表都声明的
字段名,「data-col 都在台账声明里」那条泛化断言按名字找归属,被这个撞车
巧合放过——它问的是「这个名字合法吗」,不是「这一格该不该可改」。

改成显式点名八个字段(明细表六列 + 收口两枚徽章),不再依赖泛化规则。
八次变异逐一验证:每个字段单独挂上 data-col 都能让新断言真红,
risk_level 那次额外确认了旧的泛化断言在同一次变异下仍然全绿
(复现评审报告的根因)。模版本身未改一字——它一直是干净的。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 20:15:43 +08:00
xcsandClaude Opus 5 59afcbfdbd feat(w05): 件② 差异结论审核 —— 可改的是四态,摘要与等级只读
推荐理由 / 摘要 / 风险 / 验证要求都是规则算出来的:让人直接改它们,
等于让他给自己没做的判断盖章。要改就改上面那一条四态,系统重算。

同批修一条与本次设计矛盾的既有断言:「差异审核与落账确认只读」原把
difference-review 也算只读,而 spec §4.4/§6.2 已把 S3 改成可就地编辑
——改窄成只管 landing-confirm,differerence-review 的只读面改由新增的
「件②」测试组精确覆盖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 19:50:31 +08:00
xcsandClaude Opus 5 10f529d03c docs(w05): Task 8 的示例代码也跟上助手名核正(上一笔只改了 Task 9)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 19:16:40 +08:00
xcsandClaude Opus 5 abe92e0531 docs(w05): Task 8/9 的测试助手名核正 —— activity.test.ts 里没有 viewOf 也不 import ASSESSMENT_STEPS
计划原稿让 Task 8/9 复用「Task 7 已经加进去的 viewOf / ASSESSMENT_STEPS」,
而 Task 7 的实现者核过文件后用的是既有的 viewHtml(:38 本地定义)与
manifest.assets.businessModel.steps —— 照原稿写会去找两个不存在的东西。

这个文件刻意走清单而不是 case-model.ts 的源常量:源常量对得上而清单里漏装,
正是它要拦的那类错。ASSESSMENT_STEPS 是真导出,但那是 steps.test.ts 的用法。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 19:16:14 +08:00
xcsandClaude Opus 5 40daa6c9f7 fix(w05): 评审回来的一条 Critical、一条 Important、两条 Minor
Critical:S1 数据工单的 gateLedgers 带着 parts.chip_type,而这张件没有任何手段
写它(选类型走 data-options 下拉现读,不是编辑表)—— mine:parts.chip_type 永远
是空数组,desk.ts 的 emptyGate 永远判「这一单还一行都没有」,确认钮永久按不下去,
三张真被编辑的表填得再全也一样。删掉 parts.chip_type,只留真被这张件写过的三张;
注释写明这是永久决定,不是等 Task 7b 补了新建类型的编辑表就该还原(主路是从下拉
挑已有类型,那时这张表天然零行,「零行=没干完」这条闸的前提对它压根不成立)。
新增 apps/api 侧回归探针(调用生产 emptyGate,不抄实现):证明旧配置确实会永久
卡死、新配置在同一份数据下真的放行。

Important:BIND_COMMON 的序号计数器规则只认 .tbl-wrap,而三张表的外壳是
.jc-tbl-wrap(继承自已删除的旧 P08_MASTER_CREATE 同一个坑)—— 两个 class 对不上,
「#」列在只读态与编辑态下逐行空白。补上 .jc-tbl-wrap,钉一条结构测试。

Minor:①block③ 的注释「只两栏可改」没说全 —— internal_pn/param_name 也走 cell()
(主键格必挂 data-col 是既定约定,行的钥匙),补全这句话;②data-class-of="gate_reason"
在中文自由文本上永远算不出 v-token,是源码里躺着的一个永远不起作用的属性——删掉,
显隐只留 :has(:empty) 一处,brief 给的那条断言同步改成断真实机制。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 19:11:03 +08:00
xcsandClaude Opus 5 fdcd1f88a7 docs(w05): 立 Task 7b —— 「新建芯片类型」是 spec 与用户都点名的能力,不能只活在一条顾虑里
Task 7 交付的件让人从下拉挑已有类型(主路通),但 spec :37 / :261 与用户原话都
明写还要能「新建一个类型 + 它的核心参数字段」。这一半没做。

做不了的原因已核实属实:ViewContainer.addRow 靠克隆最后一行,零行时返回 false,
而 mine:parts.chip_type 每单开局零行。顺带发现平台自相矛盾 —— desk.ts:843 那条
空表闸在教人「先在件上加一行」,而 UI 做不到。

三条候选路各记了代价:①件上摆字面量空白行(零平台改动,但复合主键的跨行自增键
联动待查)②平台补原型行(小改,但动两个共享面要全量回归)③配方种骨架(已否:
人改挑已有类型时那行空骨架会一直卡着闸)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 18:38:20 +08:00
xcsandClaude Opus 5 c60a2adf43 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
2026-08-29 18:33:07 +08:00
xcsandClaude Opus 5 b2159022bc test(w05): 补一条读真文件的引用相等断言 —— 镜像测试证明机制,这条钉住真文件此刻是对的
复审 Important:w05-s3-review-recompute.test.ts 手写镜像、不 import case-model.ts(架构上也
不能),证明的是"少了 let 会怎样"这个机制,却没有任何东西钉住真文件此刻还带着 let: SHARED_LET
——AutoRecipe.let 是可选字段,typecheck 不报;明天有人把 S3_review.auto.let 删掉,原样重犯
这次的 Critical,镜像测试的红绿完全脱钩、不会发现。

在 steps.test.ts 里"S2 与 S3 用的是同一份投影"那条 toBe 断言旁边加同风格的一行:
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let) ——
零新增 import,读的是真 case-model.ts。变异验证:把 S3_review.auto 的 let: SHARED_LET, 那一行
删掉(diff 核过只改了那一行),这条断言变红(undefined vs 期望的绑定对象);加回来
(diff 核过与原文件逐字节相同),变绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 17:47:36 +08:00
xcsandClaude Opus 5 392d085f51 fix(w05): S3 复用 S2 投影必须自带 let —— 主键靠它绑,少了就写丢或拼出垃圾行
评审 Critical:S3_review.auto 复用 RELATION_PROJECTION/VALIDATION_PROJECTION 时没有自己的
let,而两笔投影的主键/外键(relationId/evalRef/originalPn/candidatePn/typeId)全靠 let 绑的
名字 —— createAutoRunner.run() 每次都从它收到的那份 recipe.let 现算 lets,不跨步传递
(auto-runner.ts:93-97)。后果:关系表主键算成空串,写被"主键算不出来先不写"悄悄丢掉,人在
审核件上改的四态永远不会反映到推荐等级上;验证记录的主键 cat:['VAL-',{in:'evalRef'}] 反而
拼出非空的 'VAL-',写出一行垃圾,破了"P-06 一单固定一条"的不变量。

brief 让两步共享同一个对象引用(toBe 断言),推翻了 spec"S3 的配方里复制一份 ③④ 两笔……
一步一份配方,不做跨步回跳"的原话。采纳评审方案 (i):把 let 也提成模块级常量 SHARED_LET,
S2 与 S3 的 auto 各自持有 let: SHARED_LET —— 绑定的文本仍只有一处真源,运行时各自完整可求值。
核对过 SHARED_LET 五条绑定只读 sub_eval.case/sub_eval.part(S1 写、S2/S3 都不改),S3 那一拍
与 S2 那一拍算出的值逐字相同,方案 (i) 直接适用。

新增执行级回归测试(w05-s3-review-recompute.test.ts,真 createAutoRunner + 手写假端口,
参照既有 w05-gate-reason-order.test.ts 的搭法):模拟人在审核件上把一条四态改成 hard_mismatch
后重跑 S3,断言关系表真的被重算更新、验证记录仍只有一行。变异验证:去掉 let 复现两种失败
(主键算不出来的行丢失 / 拼出 'VAL-' 垃圾行),加回来两个断言都过。

顺带补 docs/debt.md 一条平台缺口:static-audit.ts 的 walkExpr 对 when/lookup 有校验分支,
唯独没有裸 {in:X} 这一支 —— 未绑定的名字装包时不出声,只在运行期现形。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 17:32:29 +08:00
xcsandClaude Opus 5 725f1ecb0c 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
2026-08-29 16:47:08 +08:00
xcsandClaude Opus 5 cd88d0e026 docs(w05): Task 11 的共享库处置按用户已给的授权定死 —— 走第①条
用户已定「走查期间先停掉稳定版」,不必让执行者再停在一个已有答案的问题上。
同时写明:停稳定版是控制端的事(本工作树之外的副作用),执行者只负责确认
:8790 没有监听;还在监听就报上来,不自己动手也不绕过去装。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 16:21:29 +08:00
xcsandClaude Opus 5 8c125042ba docs(w05): Task 10 的 Step 3/4 跟上 Step 2 —— 摘注册而非标停用,git add 补齐四个文件
上一笔改了 Step 2 的退役方式(本仓工具没有 enabled 概念),Step 3 还写着
「把注册标成停用」、Step 4 的 git add 也没带上新列进 Files 的四个文件 ——
自己改出来的内部矛盾,一并补齐。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 16:20:49 +08:00
xcsandClaude Opus 5 1c6978818c fix(w05): S2 测试的 stepOf 类型保护改用泛型 Extract,补 {} 兜底的第二个坑
评审判定 PlanStep 标注太松:.fields 落在契约的开放索引签名上,拼写保护回来了但
14 处方括号取值(含 S1 既有三条)全部失去编译期拼写检查。换成泛型 Extract 版
(case-model.ts 四处 item 补 as const 配合),按查的 item 精确narrow 到那一步自己的形状。

Extract 单独应用后 tsc 仍报 13 个错——根因是 brief 给的 7 处测试断言用 `: {}` 兜底,
而"有形对象类型 | {}"在 TS 里恒被吸收成裸 {}(与 stepOf 精不精确无关,5 个隔离复现验证过),
S1 既有断言用的是 `: undefined` 从未踩过。补齐 `: {}` → `: undefined` + `?.` 与两处
for-of 数组补 as const 后,拼错实验复现 TS2551 + "Did you mean" 建议,改回后零错误。

debt.md 补一条:analyze 出参靠 requiredWhen 兜底的字段漏填时,全 auto 步没有就近恢复点
(ADR-014 通用特性,不是 W05 的病)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 16:18:46 +08:00
xcsandClaude Opus 5 89f0be8b81 docs(w05): Task 10 修计划缺陷 —— 退役清单漏四个文件,断言写在不存在的字段上
Files 清单漏了 container.ts(容器里那行 handler 覆盖)、tools-w05.ts(两枚工具的
契约定义整份)、seed-activities.ts 与 seed-mvp.ts(它的两个消费方)。只改点名的
五个文件,工具会在一处退役、在另几处照常注册。

Step 2 的断言 registry.find(t => t.name...).enabled 三处都不成立:该文件里没有
registry;FIRST_PARTY_TOOLS 是 [name, handler] 元组数组,元素上没有 .name,取它
得 undefined 再 .includes() 当场抛;本仓工具也没有 enabled 这个概念。改成断言
「不在注册表里」,同样拦得住手滑重新注册。

Step 1 的预期由「零命中」改成实测的那几处,并写明冒出清单之外的命中要停下来报。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 16:15:10 +08:00
xcsandClaude Opus 5 60955171da 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
2026-08-29 15:40:07 +08:00
xcsandClaude Opus 5 18d7147ebd docs(w05): S1 配方次序改对 —— 案卷排到骨架两笔之后
gate_reason 引的 parts_profile_missing / parts_parameter_missing 要等
②③ 把骨架行铺出来才有值。排在前面时引擎给它读到 0,卡照样正确发出,
但卡上那句「还差什么」是空白的 —— 恰在人最想要一句解释的时候。

计划与 spec 两处都写着旧次序,不改则下游任务照抄同一个错。
S2/S3/S4 已核,次序都对(投影在前、读它的排在后),病只在 S1 一处。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 15:21:15 +08:00
xcsandClaude Opus 5 02e0d1206d fix(loop-engine): S1 次序回归测试改 import 真读数函数,删掉已经漂移的手抄
上一轮判断"读数层四个函数也躲不掉、只能手抄"是错的:rowReadings/missingReadings/
tallyReadings/distinctReadings 是 apps/api/src/app/wiring/engines.ts 的具名导出,测试文件住在
apps/api/src/modules/loop-engine/__tests__/,import 它们是 apps→apps,activities-isolated 与
apps-not-into-activities 两条隔离规则一条都不沾。真正躲不掉的手抄只有六笔配方常量本身(ACT_*,
因为那条边是 apps/* → activities/* 的反方向,才是两条规则真堵住的)。

而且手抄件已经在同一笔提交里漂了:missingReadings 拼缺项名的真身是
gaps.map((f) => f.label ?? f.name)(engines.ts:378),手抄件漏了 label ?? 那半句,连带镜像的
Declared 类型没有 label 字段,这条分支从结构上就复刻不出来。

删掉约 150 行手抄,改成 import 真身。相对路径要爬三层撞了 eslint 的 no-restricted-imports,
新增 @app/* 路径别名(tsconfig.base.json + vitest.config.ts 各一行,与既有 @modules/* / @shared/*
同一种写法)—— 取名核对过不撞 pnpm 工作区包名 @app/api / @app/web(两者从未被当作 TS import
specifier 用过,且 apps/api 的 package.json exports 只暴露了 "." 一个入口)。

变异复验(删手抄之后这条用例仍能精确复现原 bug):旧次序 → RED,Received 恰是评审报告点名的
那个空字符串,与手抄版本逐字相同;新次序 → GREEN。depcruise 单独确认零违规。

文件头注按"测什么、不测什么"重写:次序不倒退已由 steps.test.ts 用精确数组相等钉在真
ASSESSMENT_STEPS 上;这份镜像守的是引擎逐 action 刷新时机,在形状上有代表性即可,不是逐字
等于真配方的承诺。docs/debt.md 补一条,点名唯一还留着的手抄(ACT_* 六个常量)与谁改了 S1
配方形状该顺手核什么。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 15:15:18 +08:00
xcsandClaude Opus 5 defa048270 fix(w05): S1 配方次序 —— 案卷投影排到主数据/参数骨架之后
gate_reason 最深一层要读 parts_profile_missing + parts_parameter_missing,这两个数由主数据/
参数骨架两笔撑出来。案卷投影若排在它们之前,读到的是这一拍刷新之前的旧数:candidate 的类型刚被
判定、参数骨架行即将铺出但还不存在时,parts_parameter_missing 读到 0,片刻之后变成 1。收步与
发卡的把关本身没坏(card.when 排在铺骨架之后能读到真实缺口,卡照样正确发出),但案卷里存的话、
连同卡面 basis 里「还差什么」那一格,会是一句空话。

六笔重排为 part → profile → parameter → case → card → say;新增引擎级回归测试
(w05-gate-reason-order.test.ts,手写镜像 S1 配方六笔与生产读数层的四个纯函数,因
activities/apps 两侧互相不许 import 而不能直接复用真配方或真引擎的读数逻辑),变异验证过
(旧次序下测试确实变红、且红得精确:Received 恰是评审报告里点名的那个空字符串)。

顺带补一句注释:parts.parameter 的 param_unit 有意直接取字典而不回填现值 —— 同一类型下两颗料
必须按同一个单位比,字典值才是比对基准,S2 判定时用的单位同源,不是漏写了 lookup。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 14:45:57 +08:00
xcsandClaude Opus 5 ab0dfd8b49 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
2026-08-29 14:01:15 +08:00
xcsandClaude Opus 5 f22402e155 docs(spec): 拆掉 §4.3 伪码里的反模式 —— 「= 本类型字段数」不是基座数得出来的数
Task 3 的评审指出来的:parts_type_param_rows 数的是本单草稿写下的字段行(人新建类型时加的那几个),
不是整本字典。拿它当分母就是「编一个数不出来的数」—— 本仓栽过六次。
Task 3 的实现者避开了,但下游照抄伪码就会把它带回来。行数由 prune + each 字典结构保证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 13:44:32 +08:00
xcsandClaude Opus 5 f00ed5c011 feat(w05): 案例模型换成四步 —— 零 guide、零工具、零记账旗
九步砍成四步:校验入参 / 差异分析 / 结论审核 / 落账放行。
三道入场哨兵全读基座数,caseFileModel 里一面记账旗都没有 ——
旗是活动自己写的数,而「机检只读基座数」这条在本仓被违反过六次。
审核与放行触达的是人:标人工激活、不给 doneWhen。
说明书瘦成「整单的事」,示例一律占位形 —— 模型会把示例当成这一单的值。

manualActivation 未照 brief 给的 planItem+definitionRef 写法(与本仓 cmmn-parse.ts
不兼容,会解析出错误的计划项 id):改用本仓既有的单元素写法 ——
<humanTask id="..." manualActivation="true">,与 steps.test.ts 给定的 RED 测试、
E04/I01/旧九步 W05 的既有约定一致。

顺带恢复 Task 2 遗留的两条「Task 3 补回」skip(读写面 ⊆ 台账声明 / 工具 ⊆ 声明工具,
后者按零工具的新立场做了修正而非照抄旧断言);第三条(T_release judge criterion
逐字对应 acceptanceCriteria label)调查后判定为结构性不可恢复 —— 它检查的「guide
散文里嵌 judge criterion 给模型执行」这套机制随零 guide 重写整体撤掉,继续保留
skip 并订正注释说明原因。

四步重写让 activity.test.ts 的「案例模型与清单的对齐」组里 7 条既有(未 skip)断言
硬编码了旧九步结构,不改不可能 pnpm gates 绿:仍然成立的按新模型更新数字/名单
(四计划项 / 两处不给 doneWhen / 三段阶段);检查对象已被撤掉机制的转 skip 并写明
原因(P-08/P-10 双视图已合并进单一 S1_inputs;决策卡 JSON 骨架已从说明书散文挪进
配方的结构化 card 声明)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 13:22:58 +08:00
xcsandClaude Opus 5 d244864317 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
2026-08-29 12:39:45 +08:00
xcsandClaude Opus 5 7b8d3b559b feat(seed): 芯片类型字典两张表 —— 约束类型从「每颗料各标一次」搬到「按类型标一次」
两颗料是不是用同一套参数名、同一套软硬约束,从此是结构保证而不是措辞叮嘱:
字段清单挂在类型上,一颗料关联一个类型。此前靠 parts.parameter.param_name 的
自指引用墙硬咬(措辞拦了三次没拦住才砌的),那堵墙可以退休了。
另播一颗 LDO(AMS1117-3.3)—— 它是「两颗料类型不一致 → 硬拦」那条路的唯一素材。

同批修复两条因此变为过时的既有断言(均在 seed-mvp-data.test.ts,非本任务新增):
「样例料号」测试原本枚举 parts.profile 恰好两行,新增 AMS1117-3.3 后改为三行;
「标准化参数集」测试原本断言每行 parts.parameter 都带 constraint_type,该列已
搬到 parts.type_param 字典,断言相应改为 toBeUndefined()。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 11:46:27 +08:00
xcsandClaude Opus 5 94b664bc85 docs(plan): 发车前扫描三条裁定入计划(帮手真名 / found_pn_ref 去重 / assets 处置前移)
G2:Task 1 的测试帮手名是我猜的,已查实(MASTER_OBJECTS / MASTER_SAMPLE_ROWS)—— 直接写对。
G1:spec §3.3 已补 found_pn_ref,Task 2 照 spec 写就有了,Task 4 那一步改成「确认在,不在才补」。
G3:assets(dmn / skills / viewTemplates / distillHooks)的处置前移到 Task 2 ——
    不然 Task 2 到 Task 9 之间清单引着已经不存在的东西,包是不自洽的。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 11:28:57 +08:00
xcsandClaude Opus 5 65a4c7a7dd docs(plan): 第二份实施计划 —— ACT-WORK-05 活动重写(12 个任务)
spec 的 §3 数据模型 / §4 四步案例模型 / §6 三张件 / §7 判据 / §8 退役 / §9 测试 / §10 迁移次序。
自查时同步补了 spec §3.3 的 found_pn_ref —— 「在不在库」要是一次 lookup 的结果,不是一句断言,
计划的 S1 配方靠它,spec 是权威,不能两处不一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 11:15:17 +08:00
xcsandClaude Opus 5 c3fed573ee fix(loop-engine): 收尾复审二审 —— 兜底卡自己会抛 / 清单兜底不看面孔 / 空转写解闸
复审逐条核实上一波(C2/C3/M1/M2)修法本身引入的三处破坏,全部属实并修死:

- I1 二审:配方办不下去时发的那张兜底卡(deps.cards.issue)从前裸调,调人预算
  触顶抛 HumanCallBudgetExceededError 会精确复现 C3 那种无限热循环(推进器每
  2 秒重来、不烧预算、不换状态、界面纹丝不动)。包进 try/catch,失败留痕、
  状态退回 awaiting,不让它抛出去。配了变异验证:拿掉 try/catch 后新用例
  真的变红(runTurn 抛出预算触顶错误),还原后转绿。
- C2 二审:decisionCardFace 的 checklist 兜底从前不看 reviewFace —— 一步只声明
  acceptanceChecklist 而 cardVariant 不是 review(T_release 那种形状)时,配方发
  的普通 decide 卡也会被兜上清单,前端不渲染勾选区、回传门却要求 checked ⊇
  checklist,卡按不下去。改成只在 reviewFace 为真时才退回剧本声明的清单。
- M1/M2 二审:project 动作里 localExisting/localUnkeyed 触发的观察痕与真写
  (localWrote/localDropped)共用 detail.auto + detail.ledger 这两个键,而
  raiseAcceptance 的 revised 判据只认这两个键在不在 —— 一颗料 skipExisting、
  或一行主键算不出来,这条"什么都没动"的痕照样能解开重做闸,人打回重做、
  配方原样重跑一遍,闸却开了。auto-runner 补 detail.changed(真写了/真撤了
  才为真),revised 判据对配方那一路改认这个标记。

另两笔记账:
- save-wake.test.ts 的用例标题写着"验收态就是这一种(没有卡在等人)"是错的,
  pendingCards 把未办结工单也算在内,验收态恰恰有一张开着的验收工单——只改
  标题措辞,不动断言与实现。
- docs/debt.md 补两笔:保存后唤醒的收窄几乎不生效(pendingCards 口径过宽);
  模型路径的写同样会 un-land、只补了配方那一半(独立起一条,排在 W05 重写前)。

pnpm gates 退出码 0(2410 passed / 23 skipped);
git diff --stat b995148...HEAD -- activities/ 为空,九个活动包一字未动。

报告:.superpowers/sdd/2026-08-29-deterministic-steps-auto-recipe/closing-fix-report.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 09:21:02 +08:00
xcsandClaude Opus 5 c60c882f06 chore(contracts): loop-engine port 快照与 CONTRACT.md 同步(配方专用两个旗 + caseKey)
契约变更四步:
① depcruise 反查下游 —— `no dependency violations`;`draftWrite` / `writable` 的调用方
   逐个查过(engines 三处、turn-runner 两处、auto-runner 两处),新增字段全是可选,
   老调用方一个字不用改;
② CONTRACT.md 已同步(近变一节写明两个旗各治什么、跳过的行不许被 prune 撤,
   以及同批三条不变量:配方只认 liveStep / submit 与模型同一条处置 / 办不下去发卡交人);
③ 本快照单独说明:`TaskDataGateway.draftWrite` 增 `skipUnchanged`、`skipExisting` 两个
   可选入参与可选出参 `skipped`;`writable` 增可选 `caseKey`。**非破坏性**;
④ 无破坏性变更,不需要三步弃用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:45:36 +08:00
xcsandClaude Opus 5 642bdcef3b fix(loop-engine): 配方少写的行要出声,撤掉的行要点名
**M1**:主键算不出来那一行从前是静默 `continue` —— 既不计数也不留痕。
表里少了行,人和判据都查不出为什么。现在这一笔结束时点明少了几行、主键列是哪几个。

**M2**:`prune` 撤行从前只说「撤掉 M 行」。2026-08-02 为人工撤行立过
「撤要说出来、要点名哪几行」的规矩(不说清,下一拍的读者会把人刚删的写回去),
配方这一侧跟上:点名撤了哪几行,封顶 8 个,**并去掉单号那一段** ——
人正在这一单里看,单号是脚下的地面,不是区分行的信息(2026-08-25 同一条口径,
基座那一侧是 `rowLabel`)。为此 `TaskDataGateway.writable` 补一个可选的 `caseKey`。

3 测(少写出声 / 撤行点名 / 名单不带单号)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:43:53 +08:00
xcsandClaude Opus 5 588d5c2dd4 fix: 配方只认 liveStep;保存不再无端叫醒任务;prune 不在人填的时候撤行
**I6 · 配方只认 `liveStep`**:`current` 有一条兜底(`liveStep ?? 在场的第一个有剧本的项`),
它不过滤 `settledIds`。当初这只影响提示词内容;配方接进来之后它驱动**副作用** ——
再写一遍台账、再 prune 一遍,carded 为假时甚至再发一张卡。兜底继续只服务提示词。
同处 `settledIds` 改取 sweep 之后的 `stateRuntime`(`liveStepOf` 吃的是 sweep 之后的
`stateEnabled`,两边得同一份局面)。
**注**:这一形状(既已收掉又在场)我没能在当前 lifecycle 里构造出可达来路 ——
`publishEnabled` 把 Completed/Terminated 挡在 entries 与 inProgress 之外,
`reenter` 又是就地改状态、不留旧实例。用例用的是 `live-step.ts` 注释描述的那种局面
(同 id 两份实例),守的是不变式;详见最终报告。

**F1 · `afterSave` 只在有未答的卡时才 `wake`**:`wake` 对 awaiting-event / awaiting-human
一律拨成 running,推进器随即推一拍并**记一步预算**。而现有九个活动包一个 auto 都没有:
人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
(等人裁决)时被叫醒,那一拍结束还会把它写成 awaiting-event —— 从「等人办事」降级成
「等外部回音」,而等的明明还是人。**这是界面撒谎。**「保存即铺格」的场景恰恰都有卡在场。
用既有的 `humanTool.pendingCards`,不新造。`wake` 失败不再 `.catch(() => false)` 静默 ——
它是「保存即铺格」的唯一触发器,吞掉等于人按了保存、格子没出现、系统一个字不说。

**F3 · `prune` 不在人填的时候撤行**:人在件上「加一行」加出来的行,配方的数据源里当然没有 ——
编辑中途把它悄悄撤掉是最不能接受的一种静默数据丢失。有卡在等人(quiet)那几拍只铺不撤;
同一张表既被 prune 又列在该步 `humanWrites` 里,装包报 warn;规范 §4.6 纪律补两条
(prune 的表不该是人能加行的表 · card 之后那几笔要等人交回之后那一拍才跑)。

I6 2 测(做过变异验证)· F1 5 测 · F3 2+1 测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:42:06 +08:00
xcsandClaude Opus 5 2d20202b1d fix(memory): 配方留痕恢复 internal,工作日志按 detail.auto 另开例外
`internal` 是 `machinery()`(工作日志)与 `chat-projection`(对话区)**共用的一个旗**。
上一轮为了让配方留痕进工作日志,把 project / analyze 那两笔的 `internal` 整个去掉了 ——
等于同时对两个投影开门:「芯片主数据:写下 2 行」「已把 8 条参数交给模型逐条判定」
这类机械痕一笔一个气泡跑进了对话区,而这个仓花了很大代价立起
「对话区一轮一句,不是一拍一句」「引擎内部观察进日志不进对话」。

治法与 `detail.say` 同款(控制端裁定 F2):配方那几笔照旧标 `internal`(对话区据此挡住),
`machinery()` 这一侧给 `typeof detail.auto === 'string'` 开一道例外 ——
**两件事各有各的开关**。`say` 与「办不下去」那两笔本来就该进对话区,保持不标 internal。

既有那几条「留痕真的走到工作日志」的用例全绿(它们现在喂的正是 internal:true 的形状,
恰好验这条新例外);另补 8 条:auto-runner 侧断言四类痕各自标不标 internal、
chat-projection 侧断言机械痕一个气泡不冒 / say 与失败照进、
task-log 侧断言例外只认结构化的 auto 键(不是字符串就不算)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:36:09 +08:00
xcsandClaude Opus 5 647b86753a fix(activity-registry): 配方静态审补齐 spec §5.7 漏掉的几条
**`when` 从前一条都没审**(§5.7 第三条明写要审)。运行时两种下场都不好:
嵌在表达式里的 `{when}` 出错 → `evalExpr` catch 掉、**静默走 else**;
动作级的 `when` 出错 → 抛 → 整步交人。现在:
  · 所有 `when`(含嵌套在表达式里的)审语法,过 `@sfeel` 的 `staticCheck`;
  · **只有动作级**那一层连名字绑不绑得上一起审 —— 嵌套的 `when` 还看得见行内的
    `col`/`outer`/`computed`,拿 `boundNames` 去卡会误报。
    名字取 AST 的 `references` 而不是本文件那个正则:正则会把 `count(...)`
    这类内建函数名也报成未绑定的台账名。

另三处「装包期判得出、却留到运行时」的:
  · `submit.rationale` 与 `recipe.let` 的表达式从没被 `walkExpr` 走过
    (与这轮已修的 card/say 那个洞同族);
  · `project.fields` 不含目标表的某个主键列 —— 运行时那一行**静默 continue**,
    一行不写、一条痕都没有,表里少了行谁也查不出为什么;
  · `card` 没有 `basis` —— 基座拒发 basis 为空的工单(契约注释自己写着
    「别指望兜底」),运行时那一拒是抛,抛出去就是整步交人。

10 测。既有那条 `toEqual([])` 的用例把主键列补进了 fixture(那份配方本来就是坏的),
断言一个字没弱化。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:33:20 +08:00
xcsandClaude Opus 5 a09acc7504 fix(loop-engine): carded 只跳过这一笔,不收拍 —— 配方尾部的笔从前永远不跑
同一份配方 `[project, card, project]` 在两种局面下行为相反:人还在填(quiet)时
card 被 quiet 过滤 `continue` 掉,**第三笔照跑**;人交回之后(非 quiet、carded)
走到 carded 分支 `stop: true` break 掉整条 do,**第三笔不跑**。
也就是说**唯一一个「人的答案已经在草稿里」的时刻,配方的后半截偏偏不执行**;
而人还没答完时它倒是跑了。写配方的人会很自然地写「铺格 → 发卡 → 收口整理」,
然后发现第三笔从来没生效过 —— 静默,没有痕。

「收拍」留给**真发出去了卡**那一次(两条带 stop 的 return 不动)。

配套一条装包 warn:card 后面还有 N 笔时说清它们什么时候跑 —— 顺序读起来像
「一拍连做」,实际要等人交回之后的那一拍。

auto-runner 3 测(carded 跑完 / 真发卡仍收拍 / quiet 与 carded 现在一致)+
static-audit 2 测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:29:21 +08:00
xcsandClaude Opus 5 198d12af1b fix(loop-engine): 配方失败给人一条真的路;幂等在「反复进入」下也成立
**I1 · 失败不再伪装成「等外部回音」**:从前配方办不下去只 `status:'awaiting'`
就走了 —— 映射成 `awaiting-event`,而推进器只捡 `running` 的单:卡已答完、
没立监听、没有时限,**再没有任何东西会叫醒它**。人在工作台上看到「等外部回音」,
而外面没有任何回音会来。现照熔断兜底卡的成例:发一张卡把办不下去那一笔的原话
摆给负责人 + 立人事件监听。走到那儿时这一单没有未答的卡(runTurn 开头
`cards.unanswered` 把住),所以不会堆卡。不补 narration —— 那句话 auto-runner
已落成系统观察进了对话区,而且停下来的是系统不是 AI(narration 是 AI 蓝)。

**I2 · 重投不许把已落地的行 un-land**:`DraftStore.put` 的 upsert 尾巴上有
`landed_at = null`,而配方每一拍都把同一张表重投一遍 —— 数据工单确认入库之后
那些行的「已落地」记号被悄悄擦掉,整单放行会再写一遍正式表、再抛一遍 data 事件。
新增两个**配方专用**旗(模型那一路一个字不变):`skipUnchanged`(值没变就不落笔)、
`skipExisting`(「只建不改」的表碰到已在库的键当「不用建」跳过并留痕,不抛 ——
那句拒回是写给模型看的,配方抛出去就是永久停单)。已在库的行也进 `kept`,
prune 不许撤它。

M4 一并做了:auto-runner 的假 `draftWrite` 从**覆盖**改成生产的**合并**语义
(`patch || excluded.patch`),并补上 landed / createOnly 两件生产真有的事 ——
「重投不会擦掉人填的格子」这条结论在覆盖语义下根本不成立,旧替身证明不了它。

I1 2 测、I2 5 测、M4 使既有 20 测在真语义下重跑。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:27:40 +08:00
xcsandClaude Opus 5 6212b34e39 fix(loop-engine): 配方 submit 抛错不再让回合崩,重做闸也认配方的写
两处,缺一不可 —— 它们合起来是一个必然发生的无限热循环:

① 配方那一路的 `raiseAcceptance` 从前是**裸着**调的。而 `engine.advance` 不捕获、
   `task-service.tick` 也不捕获(且 `addConsumption` 排在 advance 之后,一步都不记)、
   `task-advancer` 只写 stderr 就 break,任务留在 `running` —— 下一个 interval 再来一遍:
   不烧预算、不换状态、界面纹丝不动。:1738 模型判齐那处早就包着 try/catch,
   注释白纸黑字写着理由「推进器会一遍遍重来,每遍再撞一次同一道门」。
   现在同款:拒绝 + 留痕 + status running。

② 而它**必然**抛:重做闸(2026-08-23)判「打回之后台账动过没有」只认 `kind:'action'`,
   那是模型 write/drop 的痕;配方的写留痕是 `observation` + `detail.auto` + `detail.ledger`。
   于是配方改了台账闸也看不见 → 抛。软化那一段靠 `refusedBefore`(模型路径 catch 写的痕),
   配方路径从前没有 catch,那条痕永远不写 —— **这堵墙对配方是永远开不了的**。

3 测(拒回 running / 拒过一次再提就放行 / 配方的写解得开闸),两处各做过变异验证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:21:58 +08:00
xcsandClaude Opus 5 3190392a1d fix(app): 决策卡的人工验收清单也按 itemId 兜底 —— 配方发的审核卡从前一条待确认都没有
装配层从前只给 `variant` 兜底,`checklist` 没有。配方(auto-runner)发审核卡时
只递了 variant —— 那张卡于是穿着验收工单的衣服、allowCustom 也对,**清单是空的**:
回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**,
「确认通过」当场可按。2026-08-27 用户当面定的那道人工验收闸就此凭空没了,
而屏上还看不出来 —— 清单区是空的,不是红的。

兜在装配层这一处(不是让每个调用方自己记得):模型路径将来漏传也一并保住。
判断抽成纯函数 `decisionCardFace` / `needsStepLookup`(照本仓 engines.ts 里
createOnlyViolation 那批纯函数的成例),9 测 —— 其中三条把挑出来的清单接到
真回传门上,证明闸真的因此有牙,不只是「函数回了个数组」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:20:32 +08:00
xcsandClaude Opus 5 fcf1822233 fix(app): 配方的 analyze 走 traced 网关并回账用量 —— 一条问模型不花钱的路
装配层从前传的是裸 `deps.modelGateway`:`createModelGateway` 自己不做
`addConsumption`,计量全靠调用方给的 `onUsage` —— agent 那两条路都接了,
只有 analyze 漏了。后果两条:配方里 analyze 烧的 token 一个数都不计入活动预算
(预算这道闸对它形同虚设);时间轴上也不产生模型 span,「这一单问了几次模型」
看不见它。而 spec §5.2 白纸黑字写着「用量计入预算」。

现 `createAnalyzeGateway(tracedGateway, onAgentUsage)`,网关内**两次** generate
(含重问那一次)的用量各记一笔 —— 失败的调用不是免费的。记账不吞异常:
记不上账等于预算失效,宁可当场停下由配方留痕交人。

debt.md 里那半句「它烧 token、计预算」当时是假的,改老实。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:18:10 +08:00
xcsandClaude Opus 5 bd26756da9 docs(adr): ADR-014 §3.2/§4/§5/§11 对齐落地代码 —— 三动词改五动词,拍内次序补全
评审指出 ADR-014 被标「已实现」后,正文 §3.2/§4/§5 仍是实施前的三动词提案,
与真实 schema(auto-recipe.ts)和 turn-runner.ts 的拍内次序对不上,会误导
下一个直接翻开这份 ADR 的读者(W05 重写实现者)。逐条对代码核过后分两档改:
§3.2 动词表与 §5 拍内次序按代码改对(含之前遗漏的「有卡未答就静默重跑配方」
那道前置门);§4 的两个例子不逐行改,改成开头一段按语指出三处已知出入
(workOrder→as、view 挪到 PlanStep、self/joinRows 未实现)。顺带把 §11
待定的前两条标成已定并指回销账位置。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 07:42:27 +08:00
xcsandClaude Opus 5 6cbac3220c docs(spec): W05 重写设计 §5.9 改口 —— analyze 那一笔也归系统灰,不是 AI 蓝
这份 spec 是下一份计划(W05 重写)的权威,留一句已知错的话在里面,
下一个实现者会照它做、再花一轮改回来 —— 这一轮的教训正是「三段各自正确、
合起来仍然不对」。顺带记下病根:kind: 'result' 不进工作日志骨架、internal
又被 machinery() 再滤一道,配方留痕能不能被看见没有机制保证,靠的是手工选对。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 07:27:11 +08:00
xcsandClaude Opus 5 dba6071772 docs: 确定性步骤落地 —— 规范 §4.6、ADR-014 转已实现、两笔欠账
ADR-014 §8 的两条禁令(不 submit、不发决策卡)撤掉并写明理由:
那是假设还有 agent 在场时的保守话,全程无 agent 的活动没有别人来干这两件事。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 07:24:13 +08:00
xcsandClaude Opus 5 2bb974a10d fix(loop-engine): 「配方跑完了还没算办完」交人消息改 kind: observation —— R10 第六处
这条是五(六)处里最不能隐身的:它是配方没做到位时交人的那一句,人打开工作日志
想知道的第一件事就是「为什么停了」,而从前 kind: 'result' 不在 SKELETON 里,
这条消息永远进不了工作日志,单子看着像凭空卡住。只改 kind,detail 不动。

card 动作里「这一步已经发过卡,不再发第二张」那条保持 internal: true 不改 ——
它每次重入(卡开着的每一拍)都会打一遍,是典型的排障痕,machinery() 的过滤
存在的意义就是滤掉这类;摆到人面前只会把真正要看的东西淹掉。

新增回归测试:跑真实的 turn-runner.ts,让案例落到「没 guide、没发卡、doneWhen
不成立」这条分支,把捕获到的留痕喂给 structuredLog 全程,断言可见且归系统。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 07:09:10 +08:00
xcsandClaude Opus 5 fc48920c32 fix(loop-engine): 「自动收步」留痕去掉 internal —— machinery() 挡下的第五处同病
R10:评审挖出第五个同病的 —— turn-runner.ts 那条「自动收步:…卡已交回」用的是
SKELETON 里就有的 kind: 'engine-transition',但 detail.internal: true 让它照样
被 machinery() 的第一刀滤掉,永远进不了工作日志。保留 item / auto 两个键,只删 internal。

新增回归测试:跑真实的 turn-runner.ts,捕获这一笔留痕喂给 structuredLog 全程,
断言可见且归系统。

顺带修实一条名不副实的既有用例:task-memory.test.ts:453 那条看着像在覆盖
「自动收步」这件事,但 entry() 工厂不设默认 detail,原入参是 {},根本没走到
machinery() 的 internal 过滤 —— 补上生产环境真实的 detail 形状(item / auto 两键)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 07:02:38 +08:00
xcsandClaude Opus 5 154b222860 fix(loop-engine): 配方留痕改 kind: observation —— result 不在骨架里,永远进不了工作日志
R10:评审查实 Task 12 只测了 actorOf 这个纯函数,而 auto-runner.ts 四类留痕
(say / project / analyze / 出错兜底)一直用 kind: 'result',它不在 task-log.ts 的
SKELETON 里,structuredLog 在骨架过滤这一关就把它们全部滤掉,actorOf 判得再对也没用。
project 与 analyze 两笔还带 detail.internal: true,即使 kind 改对也会被 machinery() 挡下,
一并去掉。

新增回归测试:直接跑真实的 auto-runner.ts,把捕获到的 {kind, title, detail} 喂给
structuredLog 全程,断言它出现在结果里且 who 是 system —— 不是照 spec 手写理想形状。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 07:02:26 +08:00
xcsandClaude Opus 5 09885c3dbd fix(memory): 工作日志把配方做的事归系统 —— 它不是 AI 判的,也不是人办的
不加这一条,配方的留痕会掉进 default 分支记在「任务助手」名下,日志上看着像 AI 干的。
自主率的分法要认第三档:确定的事交系统,判断交 AI,责任交人。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 06:32:52 +08:00
xcsandClaude Opus 5 35ef0f1790 feat(desk): 件上保存之后叫醒任务 —— auto 步的配方才有机会按最新草稿铺格
从前只有两条回传路由调 wake;保存不是回传,于是「保存即铺格」永远等不到那一拍。
叫不醒不抬手:保存本身是成功的,不该因为叫不醒把 200 变成 500。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 06:32:45 +08:00
xcsandClaude Opus 5 6f721ffc6d fix(interaction): data-options 收尾防重改认显式标记,不再靠「有没有 selected 这个词」
评审复现了一个真泄漏:selectCurrent 用 /\bselected\b/.test(block) 当「行内那一遍
处理过没有」的代理 —— 而一行被正确处理过、且合法地落在「没有选中项」上(值不在
选项里,或这一格还没填),跟「压根没被处理过」在字面上是同一个样子。renderView
收尾那一遍拿外层作用域重新处理这块,若外层恰好有个同名裸键命中某个选项,就会把
它错误地渗进这一行 —— 界面撒谎:显示一个这一行数据里根本不存在的选中态。

改记一个跟内容无关的标记属性 data-sel-scoped:处理完(不管有没有真标出 selected)
都打上,下一遍见到标记就跳过;renderView 收尾前把它整个剥掉,不留进交给沙箱的成品。
顺带治了评审点的 Minor #1:旧写法还会被 <option> 显示文字里恰好出现的 "selected"
误判(枚举标签写成 "Selected Value" 那种)。

三条新用例:行内下拉值不在选项里 / 新加的空行,均验证不再被外层同名值渗透
(RED 证实了泄漏确实发生过);行外独立下拉仍正确标好 selected(防重归防重,
不能把本该工作的那条也防没了)。

docs/HTML报告生成规范.md 补一句:data-options 与 data-slot 不能共存,后者会被
fillOptions 摘掉不起作用(评审 Minor #2)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 06:21:26 +08:00
xcsandClaude Opus 5 81fb8179c2 feat(interaction): data-options —— 下拉的选项从台账现读
芯片类型是人在件上现造的,写死在模版里等于「只能选出包那天就有的那几种」。
与 data-rows 同一层机制:只取值不判断,选中哪一个仍由 selectCurrent 按行内的值定。
空选项一律留着 ——「还没选」得说得出来。

brief 给的实现有两处会让测试落空,已按 TDD 修正并留痕在代码注释里:
①产出的 <select> 若留着 data-slot,renderTemplate 的 SLOT 规则会把整段 <option>
  当文本槽冲掉(<select>2</select>)—— fillOptions 需要把 data-slot 一并摘掉;
②selectCurrent 只在 renderRows 的逐行流程里被调用,不在任何 data-rows 里的下拉
  永远等不到人给它标 selected —— renderView 收尾再兜底调一遍(用外层作用域),
  已经被行内定过的整块原样放过(靠判断块内是否已有 selected,不靠字符串层面
  分不清的「是不是在行里」)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 06:03:45 +08:00
xcsandClaude Opus 5 9ab2f4b695 fix(activity-registry): 配方静态审补 card/say 的表达式扫描(评审 Important)
card.title / card.ask / card.standard / card.basis[].v 与 say.text 全是 AutoExpr,
合法地可以含 lookup —— 原实现只核了 gateLedgers,这几处从没被 walkExpr 走过,
卡面上的一处笔误因此能一声不响地装进来,只在这一步第一次真跑起来时才现形。

补齐四处 walkExpr 调用 + 新增 say 分支;顺带核实 walkExpr 对 when/then/else 这类
嵌套结构确实递归到底(title 的测试用例特意嵌了一层 when/then 验证)。

同批把名不副实的「each / lookup 的地址同样要在声明里」拆开:该用例改名只测 each,
另加一条真的在 project.fields 里放 lookup 节点的用例(评审 Minor)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 05:43:53 +08:00
xcsandClaude Opus 5 3636496774 feat(activity-registry): 配方静态审 —— 写错地址在装包那一刻就拒
与 refersTo 目标检查同款:代码错是必然的错,而必然的错只有在装的时候被拦下才算好查。
台账地址 / 列名 / 模型名 / 视图模版 / submit 的验收清单逐条核;
analyze 的出参没人消费报 warning —— 那是白烧一次模型调用。

顺带(R8,Task 7 评审挖出):全 auto 的步没有 guide 是正常形态,
guide.min(1) 放宽为 z.string() + superRefine —— 没 auto 又不写 guide 照旧拒装。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 05:29:47 +08:00
xcsandClaude Opus 5 f59e237035 fix(loop-engine): auto 收步补两处 —— 真测 carded 门 + 半 auto 不抢模型的活
评审 Important:「卡还没发过就不收」那条用例其实没走到 carded 门(命中的是 Task 7
既有的发卡分支),换一条真正让 card 被 when 挡掉、只有别的笔照写的用例才让执行落进
carded 门;原用例改名留作 Task 7 那条路径的回归,逻辑不动。

R9(基于评审 Minor #1 的裁定):收步条件加一项 guide 为空 —— 半 auto(声明了非空
guide)的步,人答过卡之后那份判断仍是模型的活,不该被这段抢在模型前面收掉。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 05:13:24 +08:00
xcsandClaude Opus 5 343a281af5 feat(loop-engine): auto 步的卡答过了就收步
四个前提都成立才收:声明了 auto、卡发过、这一拍没有未答的卡、配方也没再发新卡。
卡还没发过就不收 —— 那才是替人签字。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 04:47:07 +08:00
xcsandClaude Opus 5 80e9b01f84 docs(plan): R8 —— 全 auto 的步要能声明「没有 guide」,并进 Task 9
Task 7 的评审挖出来的:manifest.ts 的 guide 是 z.string().min(1)(必填非空),
而全 auto 的步按设计就没有 guide —— 引擎正是按 guide.trim() === '' 分半 auto 与全 auto。
照现在的 schema,第二份计划里四步全 auto 的活动会在装包那一刻被拒。
改成 superRefine:声明了 auto 才允许 guide 为空;没有 auto 的照旧拦
——「作者忘了写做法」是个老错,那道门要留着。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 04:42:54 +08:00
xcsandClaude Opus 5 fbd5867206 feat(loop-engine): 拍首跑配方 —— 确定的活由引擎做完,剩下的才问模型
liveStepOf 从 turn-runner 提纯成纯函数(带 repetitionRule 的项收了仍 Enabled,
不跳过已收掉的就整单钉在第一步 —— 这条实测栽过)。
有卡在等人时配方照样跑但只算只写:人在件上按了保存,该铺的行就铺出来。
老活动一个字不用改:剧本里没有 auto,这一段整个不进。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 04:28:18 +08:00
xcsandClaude Opus 5 e9da8d25f5 feat(loop-engine): card 与 submit 两个动词;审核卡放宽到单选项
ADR-014 §8 原写「不发决策卡、不 submit」—— 那是假设还有 agent 在场时的保守话。
全程无 agent 的活动没有别人来干这两件事,不开口最后两步就没人做。
护栏没松:submit 仍要过提交门(判据全绿),系统提交不比模型提交宽松。
发过的卡不再发第二张;发了卡就收拍,排在后面的笔一律不跑。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 04:08:59 +08:00
xcsandClaude Opus 5 fe8de5319c feat(loop-engine): analyze 动词 —— 系统组装入参、声明出参 schema、系统把结果落表
AI 在这里只是一个纯函数:不给工具、不给记忆、不多轮。
行数不由它决定 —— 出参是拿主键去 lookup 的一张查表,它漏一条那一格就是空,
required 立刻把它变成 _missing > 0,提交闸与放行门当场拦住。
出参不成形就地重问一次,再不成抛出去交人。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:54:14 +08:00
xcsandClaude Opus 5 b0a3cc3a55 feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数
三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0);
值没变的写静默跳过(配方不是模型,原地打转不必被看见);
出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。
主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:41:34 +08:00
xcsandClaude Opus 5 58ff4f1b90 feat(loop-engine): 配方数据源展开 —— once / rows / each,嵌套两层封顶
里层的 where 认得出外层那一行:外层字段还是空的时候一行都不命中 ——
「人挑了类型、按了保存,参数格才出来」不是特判,是这一条的自然结果。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:24:58 +08:00
xcsandClaude Opus 5 f950a4419d feat(loop-engine): 配方表达式求值 —— 八种形状,S-FEEL 仍由外面注入
地址四种、取不到一律空数组(首拍很多表本来就没行,抛了等于开局交人);
比值一律按字符串(台账里数字与文本混着存);谓词算不出来走 else,不拖垮整拍。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:14:46 +08:00
xcsandClaude Opus 5 fc8b3c504b fix(lint): eslint 忽略 .stable/** —— 部署快照是产物不是源码
08-28 stable.sh 改造开始往 .stable/src 放源码快照(gitignore 里排着),
eslint 却照 lint 不误:24 条错、pnpm lint 对谁都是红的。
它与既有的 dist / .build / api-report / .api-tmp 同类 —— 那份代码改不动
(下次 stable.sh update 就覆盖),真源是它构建自的那个 commit。
长红的门禁等于没有门禁:人会学会忽略 lint 输出,那才是真损失。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:09:30 +08:00
xcsandClaude Opus 5 16fe8ba71b feat(contracts): auto 配方契约 —— 确定的活由引擎按声明做,五个动词四种地址
配方是数据不是代码(ADR-004:不执行包代码即知包为何物),装进来可静态审、可回放。
只声明真实现了的那几式;嵌套两层封顶,没实现的形状 zod 当场拒。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:04:56 +08:00
xcsandClaude Opus 5 04234d2fda docs(plan): 发车前扫描的三条裁定入计划(R1 测试桩前移 / R2 去类型逃逸 / R3 推帧改条件)
R1:TurnRunnerDeps 加 analyze 会让 turn-runner 测试脚手架的 deps 字面量少一个必填属性,
    typecheck 在 Task 4 提交时就红 —— 桩前移到 Task 4,Task 7 那一步退化成验证。
R2:auto-source 的 matches() 在生产域代码里用了 as never,形参直接标 AutoExpr。
R3:showView 第二参不可选,传 undefined 编不过 —— 改成「声明了画面才推」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 03:00:08 +08:00
xcsandClaude Opus 5 b9951483bc docs: ADR-014 确定性步骤 + W05 重写设计 spec + 实施计划(第一份:平台机制)
spec 定死八条:配方驱动零工具 · 六张台账 + P-06 按结论入账 · 四态 AI 定等级规则算 ·
保存即铺格 · 类型不一致硬拦 · 审核卡只有确认 · 只做真用得到的那几式。
计划十三个任务,机制先在纯函数与假端口上钉死,再去接真活动。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 02:56:39 +08:00
xcsandClaude Opus 5 2d1a2ff442 chore(stable): stable.sh 在途改动落下
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 02:56:39 +08:00
xcsandClaude Opus 5 91840067d4 chore(w05): 在途改动落下 —— 启动表单收到三格、humanWrites 那一版清单
上一轮的工作树遗留(packageVersion 1.6.5 → 1.7.3):客户编码与客户名称从启动表单撤下,
三格全选填;活动内部仍认客户,运营端起的单缺这两个键的账已记在注释里。
本轮机制改造不碰活动包,先把它落下,免得混进后面的 diff。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 02:56:39 +08:00
xcsandClaude Opus 5 a370616f01 CLAUDE.md 与 debt:humanWrites 那条入快照;记下更大的一条 —— 确定性步骤没有落点
用户同时点出(未做):**存在性核对 + 派数据工单是确定性流程,根本不该交给模型** ——
W05 的「逐个料号查库 → 不在库就铺骨架、发数据工单」是纯规则,却写成了模型的 guide,
因为一个计划项的做法只有一个落点(模型的 guide):纯工具只读、判定表不写台账也不发卡、
`doneWhen` 只收步、哨兵只开门,**没有一个机制能整段承接**「读台账 + 写草稿 + 发人卡」。
引擎三样能力全有(`taskView` / `draftWrite` / `issueExecution`),缺的只是声明面。
代价已经付过两次:08-27 原 PN 不在库却判「在库」;08-28 模型把原 PN 的规格书抄到候选头上。
真治法:剧本加 `auto` 步(声明「这一步引擎按规则做,不问模型」)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 12:38:26 +08:00
xcsandClaude Opus 5 2cef6f7489 feat(contracts): PlanStep.humanWrites —— 人填的表模型不许写;还欠一张卡的那一步不摆 doneWhen
柴浩实测单 6ddcecf8(连撞五拍熔断):P-10 资料记录的标题 / 版本 / 链接与台账里 `LM2596S-5.0` 那一行
**逐字相同** —— 不是幻觉,是「手边有一行看着对的就拿来用」(那一步 reads 摊开了 `parts.document`,
视野里唯一一份真资料就是原 PN 的)。行找对了、料找错了,而这份假证据会直接喂进差异比对当「硬约束有证据支持」。
三层合谋:①把 P-08 的回卡认成 P-10 的(P-10 的卡从没发过);②08-27 给这一步加的 doneWhen 把
「资料必填不缺」摆成目标、后面还跟「成立了就 finish」,**与同一份提示词里「唯一合法的动作是先发那一张卡」
直接打架**;③它顺手把 `exists_status` 改成 created,按剧本发卡名单自己空了 —— 一个合法动作都不剩。

止血两刀:
①**`PlanStep.humanWrites`**(契约,可选字段、老活动一个字不用改):声明的台账 `write` 当场拒并指路
  「去发那张卡,值由人填」;`drop` 不拦(写错了得撤得掉)。判法认点名的项,点不出退回当下在办的那一步
  (压拍时骨架写在上一步名下正好放行)。**只声明「模型永远没有来源」的表** —— 有附件时该预填的
  (P-08 的参数值从 datasheet 抽)别声明,那是它的活。
②**还欠一张卡的那一步不摆 doneWhen** —— 判据同时是摆在模型眼前的目标,而它此刻只许发卡。

W05@1.6.5 声明 `humanWrites: ['parts.document']` 并从 writes 去掉。4 测(墙 / 放行 / drop / 声明)。
**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑那个判据。**
契约变更四步:depcruise 反查下游绿、loop-engine CONTRACT.md 同步、api-report 随本提交入库、
可选字段非破坏性。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 12:38:26 +08:00
xcsandClaude Opus 5 e1d8d86b16 合并 origin/mvp-foundation:运营中心收口(四区建设中占位 · L2 文案与收口)+ 治理台告警钮暂隐(shanqin 两条)
CI / ci (push) Failing after 10s
主支这边一条:实例时间线第 9 步的结果跟第 8 步走(已放行的单不再永远停在 pending);
远端两条是队友的前端:运营中心四区(驾驶舱 / 工作流 / 台账 / 系统)摆建设中占位、
L2「启动活动」改「创建活动」、沙箱区块隐藏、行内终止撤掉收口 L3、负责人小签隐藏、
文案定名活动列表 / 活动号;治理台顶栏告警钮暂隐(SHOW_ALERTS 开关,计数逻辑保留)。

两边文件零重叠(后端 activity-ops + API 文档 vs 前端 gov-shell / panel + 需求文档),自动合并无冲突。
五道门禁绿:2260 测试通过、23 跳过(无模型密钥 / 无真信箱的惯常项)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 10:51:08 +08:00
xcsandClaude Opus 5 c1137fb09a fix(ops): 实例时间线第 9 步的结果跟第 8 步走 —— 已放行的单不再永远停在 pending
放行那一步(T_release,人工激活)**没有自己的收步事件** —— 人签字后 `submit` 直接把整单落终态,
计划项本身从没被 finish 过。于是一单已 `released` 的详情里第 9 步永远是 `pending`,
而它前面拟落账那一步早就 `done`:同一张单上两句话互相打脸。
`followRes` 纯函数在服务端交出详情之前把第 9 步的 `res` 与 `at` 跟上第 8 步(2026-08-28 用户定,`at` 也跟);
**用时不编** —— 那一步没有自己的起止痕;两步有一步不在(别的活动步数不同)就什么都不动。
《API文档-活动模块》§ 时间线补一行口径。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 10:50:16 +08:00
shanqin fff2e6ec2d 治理台顶栏告警钮暂隐(SHOW_ALERTS 开关,计数逻辑保留)
CI / ci (push) Failing after 11s
2026-08-28 10:47:33 +08:00
shanqin 21d41f5859 运营中心收口:四区(驾驶舱/工作流/台账/系统)摆建设中占位;L2「启动活动」改「创建活动」、沙箱区块隐藏、行内终止撤掉收口 L3、负责人小签隐藏;文案定名活动列表/活动号
CI / ci (push) Failing after 12s
2026-08-28 10:39:13 +08:00
xcsandClaude Fable 5 f41bd76b0e CLAUDE.md:今日五条入快照 —— 兜底轮询 · 录入卡占位 · 差异比对三刀
CI / ci (push) Failing after 11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:08 +08:00
xcsandClaude Fable 5 88f2149d12 差异比对三刀:客户约束不再一票否决 · 入参由基座从台账取 · 「下限~上限」一律读成范围
①**两颗一模一样的输入电压范围判成 MISMATCH**(用户送来 0bddd862):客户写「45V 以内」→ requirement `>= 45`,
而原 PN 与候选上限都是 40V;`judgePair` 只看候选过不过客户线、不看原 PN 自己过不过 ——
需求边界与在用料的差距记在了候选头上,硬约束 MISMATCH → 整颗不推荐。差异评估比的是「换上去会不会比现在差」:
候选与原 PN 相等 / 盖住、且客户线原 PN 也过不去 → 仍按比对结果判(MATCH,风险中),备注点名
「需求边界,请回头核对需求」;候选更窄且过不了线仍 MISMATCH(3 形有测)。
②**入参由基座从台账取,模型只报料号与客户约束**(用户:「这个做差异评估比对的 harness 肯定有问题」):
同一单痕里模型念出「输入上限 32V 低于客户 40V」—— 那是 S3 剧本示例的原话、不是这单的数;
工具入参只存哈希,喂了哪几对值事后查不到;写回时还把工具的 `equal` 改成 `less`。两头都靠模型抄、两头都没有基座核。
改三层:工具运行时把 `taskId / activityId` 交给处理器(`ToolHandler` 入参加两个可选键);
`compare-parameters` 给 `originalInternalPn + candidates[].candidateInternalPn(+ requirements)`,
参数行从 `parts.parameter`、封装 / 生命周期从 `parts.profile`、证据引用从 `parts.document` 由工具现读任务视图
(本单草稿也算),出参每项带原值与证据,写盘照抄;老形状(自带 pairs)照接,试运行走它;
container 用 `compareParametersWith(ledger)` 顶掉静态名单那份;≤800 字的工具入参整份进 `result` 痕(审得了才谈得上认真)。
**基座能读到的事实,不让模型转述给工具。**
③**「下限~上限」一律是范围**(用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚」):实测解析器真有洞 ——
单位写进值里(`4.5V~40V` / `-40℃~+85℃`)被读成单个数,Unicode 减号、`…`、`≥45 / 40 以内` 读成文本。
分隔符认 ~ ~ - – — … 至 到 to、单位可夹在值里、单边界读成半开范围;出参每项 `readAs:{original,candidate}`
写明「范围 4.5~40 / 定值 5 / 下界 ≥ 45 / 文本」—— 比对依据可见,模型照它写影响、不自己重新解释值;
契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。
工具 1.0.0 → 1.3.0(注册一名一行、upsert 换版本,handler 在进程内按名绑,装完稳定版重建即生效);
W05 包 1.6.2 → 1.6.4:S3 第一拍只报料号与约束、示例只留占位形,第三拍 compare_result 照抄,S4 重比只改 requirements。
13 工具测 + 包内 89。在途单要人在审核卡上「重做」并写明意见才会重比。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:07 +08:00
xcsandClaude Fable 5 e3479e0b9b fix(web): 录入卡在等人时,工作区摆占位页,不摆件
建档那一步(「客户 123 不在客户档案里,请核对建档」)要人做的事全在对话区那张录入卡上,
而工作区里躺着的是**上一拍**推的 HTML 件 —— 摆着只会让人以为要在那份工单上办点什么。
`pendingCardsOf`(纯函数,5 测)认出未确认的录入卡 → `ViewRegion` 在 running 之后、viewHtml 之前
插一支占位 `EmptyState`(点名那张表的人读名,指向对话区)。
决策卡**不走**这一支 —— 人要决定的对象常常就是件上那份稿,件得留着给他看。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:07 +08:00
xcsandClaude Fable 5 282584d26e feat(web): 工作台任务界面给 SSE 配 10 秒兜底轮询 —— 只此一处,由壳层起停
`app/fallback-poll.ts`:任务界面开着时每 10 秒把 `desk` 下**在用的**查询(清单 / 这一单 / 角标,
`type:'active'`)各重取一次,推送漏了画面最多旧 10 秒。**不取消在途**(`cancelRefetch:false` ——
慢接口不被兜底打成永远取不回)、页签藏起不打、回前台先补一趟;壳层 `useEffect` 起停,
切模块 / 卸载即 `stop()`(定时器与可见性监听一起摘)。`doc` 可注入,4 测钉死
(周期 / 停止 / 可见性 / 在途去重,第四条做过变异验证)。
`queryKeys.desk` 新增作整支前缀,订阅层的裸 `['desk']` 改用它。
编码规范 §9.2「组件禁止自行轮询」照旧,补一句:兜底只此一处、由壳层起停。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 01:13:07 +08:00
xcsandClaude Fable 5 f77c6325fe 合并 origin/mvp-foundation:活动模块接真接口 + 员工模块界面改版 + 启动抽屉「责任人」+ 身份菜单直链运营中心(shanqin 三条)
CI / ci (push) Failing after 11s
主支这边:W05 1.4.1–1.4.7 七条 + 刚合进来的 feat/activity-ops-w05(运营中心活动模块接口 L1/L2/L3、
W05 在途修订、同日 harness/工作台修);远端三条是队友的前端:活动模块 L1/L2/L3 接真接口(顺手按接口文档
补了 api/activity-ops.ts 契约)、员工模块界面改版、启动抽屉固定参数「责任人」、工作台身份菜单直链 /ops。

冲突三处,全是两边各写了一版同一样东西:
① `packages/contracts/src/activity-ops.ts` / `api/activity-ops.ts`(add/add):取后端那版 —— 队友是照
   《API文档-活动模块》补的,43 个导出同名、路由 12 条全同,只差 launchOptionSchema / INSTANCE_REASON_KINDS /
   STEP_RESULTS 三个名字;队友前端对着后端契约原样编译通过。
② `docs/API文档-活动模块.md`:取后端按契约回写的那份(已含 §5 责任人 ownerId、combo 语义、关键台账看板一节、
   候选料号改单选);队友那侧是回写前的旧稿。
设计端活动库两条路由改名 `/api/gov/activity-packages` 让位随 feat 分支进来,队友契约与治理台同名路由撞车那 7 条
路由注册表测试(远端主支自己红)就此消解;api-report 按合并树重刷。

五道门禁绿:2240 测试通过、23 跳过(无模型密钥 / 无真信箱的惯常项)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:22:57 +08:00
xcsandClaude Fable 5 a40fc1a459 合并 feat/activity-ops-w05:运营中心活动模块接口(L1/L2/L3 十一条)+ W05 在途修订 + 同日 harness/工作台修
三条(0923fbf / 909b1fe / 7b85a79)是 2026-08-27 下午到晚上几个会话在工作树里做的,今天补提交。
它们互相依赖,验证以下一条合并(远端主支)后的五道门禁为准。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:42 +08:00
xcsandClaude Fable 5 7b85a79323 harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:16 +08:00
xcsandClaude Fable 5 909b1fe4b7 W05 在途修订:差异评估结论审核件一个候选一张表 · 关键台账数据看板件(ledger-board)· S1 清单不再把人刚填的参数摆成缺口
活动包 `part-substitution-assessment`(case-model / manifest / report / target-style / badge / ledger-board 与两份包内测试)、
目标件「验收工单-01-差异评估结论审核」、`docs/ss02-act-05` 关键台账数据两份、端到端测试记录、e2e 脚本。
逐条口径见同批 CLAUDE.md 快照。2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:15 +08:00
xcsandClaude Fable 5 0923fbfe4d feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params /
summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts`
(与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema);
迁移 0035_activity_ops;《API文档-活动模块》按契约回写。
设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*`
整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。
2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:15 +08:00
xcsandClaude Fable 5 75553371b0 审核通过与否读基座数;「重做」不再被指向 finish;审核卡摆成验收工单(W05 1.4.7)
用户两报:①「我上一步明确要求重做了,怎么会继续向下推进」②「验收工单弹出的是需要你定,明显不对」。

①病根两处合谋:人两次答 custom:重做一次,模型把一模一样的问法又发了一遍被闸拦下;而拒回的话
不分答案一律说「照这个选择把结论落进台账那面旗,再 finish」—— 对通过是对的,对重做是把模型往错路上推,
它照办写了 review_approved_total = 1,而 S4 的哨兵读的正是这面模型自写的旗(被叫的那一方自己记账,A6 那一课)。
- 基座新读数 decision_last / decision_<值>_total(按 card-reply 数);S4 哨兵改读 decision_last = "approve",
  人最后一次真点了通过才开门;静态审认这几个名字。
- 拒回 / 观察 / 事实行三处同一段 chosenGuidance 按答案分岔:custom: 不是通过 —— 按他写的改、换问法重发、
  不许写通过旗、不许 finish;选项值指回说明书那一档。
- S4 剧本驳回分支明写不许写旗、ask 开头必须写明这一轮改了什么(字面一样的卡会被吞)。
②平台的验收工单是终局,中间审核只能靠决策卡循环 —— 但不该穿「需要你定」的衣服:契约 decisionCard.variant:'review',
  PlanStep.cardVariant 扩到 'review',cards.decide 带 itemId,引擎按剧本决定;对话区摆成「工单 · 验收 / 审核」。
2106 测 / typecheck / lint / depcruise 绿;api-report 已刷;CONTRACT.md 同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 14:46:45 +08:00
xcsandClaude Fable 5 94e66d5a58 差异明细表头只留中文;落账确认「字段」格同款(W05 1.4.6)
用户:「AI 产出的差异明细这个表格表头只需要中文描述就行了,字段名就不要了」。
差异明细表头去英文列名(含窄屏列标 data-h);落账确认页「字段」格是同一种毛病(19 处),一并去掉
—— 数据工单页、审核页各指出一次,这页不等第三次。测试改成五张件逐张断言表头与字段格不含英文列名。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 14:32:52 +08:00
xcsandClaude Fable 5 91458219ee 两张验收件的表也要能横滑:.tbl-wrap 与 .jc-tbl-wrap 一并放开(W05 1.4.4)
用户:「差异评估结论审核这个页面,表格部分不能左右滑动」。结论审核 / 落账确认两张件照目标件用
.tbl-wrap 包表,验收版目标件写的是 overflow:hidden(只有数据工单那版自己补了 overflow-x:auto),
上一刀只管了平台包装 .jc-tbl-wrap。规则扩到两种包装;测试改成五张件逐张断言。
静态渲染实证:两张表 overflow-x=auto,装进 600px 的框里明细表能滑 150px。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 14:20:43 +08:00
xcsandClaude Fable 5 b98ec75a89 数据工单由剧本声明、引擎决定 —— 不靠模型抄卡骨架(W05 1.4.3)
用户:「右边 AI 聊天框里这个数据工单怎么又显示的执行工单?」查实:说明书骨架里
`"variant":"data"` 与 `gateLedgers` 并排写着,模型抄卡时 gateLedgers 抄到了、variant 丢了
(看着像元数据)。靠模型抄一个标记本来就靠不住。

改成与 `view` 同一条道理:这一步派的是什么单,写剧本时就定死 ——
- 契约 `PlanStep.cardVariant: 'data'`;活动在 S0d_p08_master / S0d_p10_document 两步上声明;
- `cards.issue` 入参多一项可选 `itemId`(turn-runner 从 human 动作带下来;引擎自发的验收/兜底卡没有项);
- 组装层按 itemId 查剧本,`stepVariant ?? 卡上写的` —— 模型写不写都一样。
在跑那一单的卡已就地补上 variant(payload),刷新即见「数据工单」。
2102 测 / typecheck / lint / depcruise 绿;api-report 已刷;两处 CONTRACT.md 同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 13:51:14 +08:00
xcsandClaude Fable 5 ffa1ef1076 件上按 ×:刚加的行直接拿掉,原有的行挂记号 —— 且记号要看得见(W05 1.4.2)
用户:「编辑点击再加一行后,点击旁边的叉没有反应」。两层病:
- 件:E04 那套 jc-row-gone / jc-row-new 的样式挂在它自己的外壳类下,W05 按目标件重做后
  表是 table.on,一条都没跟过来 —— × 按了类是切了、屏上没动静。补四条。
- ui-kit:刚「加一行」加出来的行按 × 也只是挂记号 —— 它从没存在过,挂记号再保存送出去的是
  一条「撤掉一行草稿里没有的行」,服务端只能回认不出。抽成 dropRow:jc-row-new 的直接移除,
  原有的行挂记号、再按摘掉;jsdom 钉两条。
浏览器在用户在跑那一单上实证:加行 1→2,× 后 1;原有行 × 一次挂记号、两次摘掉;取消回 1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 13:42:35 +08:00
xcsandClaude Fable 5 fa27cef60e 数据工单只建名单上的料:拼出来的料号与在库的料号写下来都当场拒(W05 1.4.1)
用户在新一单上看到件上多出一行要人补的料号。查实:模型在数据工单里给一颗**不存在的**
XL2596S-ADJ 铺了空骨架 —— 库里没有、入参里也没有,是把两个候选的名字拼出来的;
件上于是多出一行、红带点名它缺品牌。存在性核对本身判对了(ADJ found、XL absent),
错在「判对了却多写了一行」—— 事实在模型手里,能判对也能写错。

两道门,都在写入那一刻:
- `parts.profile.internal_pn refersTo sub_eval.pn_check.internal_pn` —— 只许建核对表里核过的料号,
  凭空拼出来的当场拒(参数/资料两张子表的 internal_pn 本就引用 profile,随之一并拦住)。
- 契约新增对象声明 `createOnly`(平台侧,~25 行):标了的表本单只建不改,键已在正式表当场拒并指路。
  它守的是另一形(在库的料被当成新料重建)。只管 Agent 的写路径,人在件上的保存不受此限。
纯函数 `createOnlyViolation` 可测(复合主键按全部键列比);活动侧三张 parts 表标上;剧本补一句。
当前在跑那一单的草稿里拼出来的那行已手工撤掉。2100 测 / typecheck / lint / depcruise 绿;api-report 已刷。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 13:28:25 +08:00
xcsandClaude Fable 5 51c87af583 数据工单有了自己的面孔;件上四处样式对齐;缺库不再问人;5173 反代改回本机
用户在工作台上连报六条(2026-08-27):
- 对话区把数据工单摆成「执行工单」→ 契约 ExecutionWorkOrder.variant:'data'(引擎侧与执行工单无异,
  只换面孔),归一层从卡上带下来,web 新增 DataForm(同一套办法、另一副面孔:色/图标/「数据工单」/「确认写入」),
  办结卡与治理台人类工具目录同步;活动的两张数据工单卡骨架标 variant:"data"。
- 表显示不全不能右滑 → 目标件把 overflow-x:auto 写在 .tbl-wrap,平台包装是 .jc-tbl-wrap,谁都没管到;补一条。
- 表头只留中文 → 三张数据工单表去英文列名与 .thd 小字。
- 编辑态内部料号「只读字 + 输入框」并排 → 目标件 #tbl1 .jc-vcell{display:inline}(id 特异度)盖过
  table.on 的隐藏规则,表 1 每格其实都并排,PN 那格恰好有字;用 id 特异度覆盖回去。
- input 与 select 没对齐 → 徽标从前包 .ro 编辑态留着、压在下拉框头上把那格顶低 13px,改成随只读格一起藏;
  input/select 钉同一高度。
- 第一步「派数据工单还是放弃候选」不该问人 → 决策卡退役(覆盖规格「候选 ≥2 可放弃」那一条,用户定),
  查无记录一律自动派;剧本/说明书/测试/e2e 同步(e2e 新增 expectAsks.max 断言「不该问的没问」)。
顺带:5173 的 /api 反代默认指向一条公网隧道(150.158.83.131:46100 → nps → npc → 本机 :8787),
浏览器每次请求先出公网再绕回来;默认改回本机,要远端的显式给 API_PROXY_TARGET。

静态渲染实证:900px 宽的框里横向能滑 200px;编辑态同一行 3 input + 1 select 全部 32px 同一 y;
只读 PN 格 display:none。活动包 1.4.0;2097 测 / typecheck / lint / depcruise 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 12:36:13 +08:00
216 changed files with 36769 additions and 5179 deletions
+675 -2
View File
@@ -1336,6 +1336,17 @@
让用例失败而不是把测试进程撑爆 —— 第一版真·无限递归 OOM 了)。两侧任一打上补丁回弹即断。
连带看见但未动:`task-service.tick` 每拍无条件 `setPhase` 广播 + 每拍推帧 + 推进器 `advanced`,一拍
3-4 条 `task` touch,前端 `invalidateFor` 无合并 —— 推送有多密请求就有多密(记 debt)。
- **工作台任务界面给 SSE 配了 10 秒兜底轮询**(2026-08-28 用户定):`app/fallback-poll.ts` ——
任务界面开着时每 10 秒把 `desk` 下**在用的**查询(清单 / 这一单 / 角标,`type:'active'`)各重取一次,
推送漏了画面最多旧 10 秒;**不取消在途**(`cancelRefetch:false`,慢接口不被兜底打成永远取不回)、
页签藏起不打、回前台先补一趟;壳层 `useEffect` 起停,切模块 / 卸载即 `stop()`(定时器与可见性监听一起摘)。
`doc` 可注入,4 测钉死(周期 / 停止 / 可见性 / 在途去重,第四条做过变异验证)。
§9.2「组件禁止自行轮询」照旧:兜底只此一处、由壳层起停。`queryKeys.desk` 新增作整支前缀。
- **录入卡在等人时,工作区摆占位页,不摆件**(2026-08-28 用户看柴浩的差异评估单定的):建档那一步
(「客户 123 不在客户档案里,请核对建档」)要人做的事全在对话区那张录入卡上,而工作区里躺着的是
**上一拍**推的 HTML 件,人以为要在那份工单上办点什么。前端判:`pendingCardsOf(detail.chat)`
(纯函数,5 测)认出未确认的录入卡 → `ViewRegion` 在 running 之后、viewHtml 之前插一支占位
`EmptyState`(点名那张表的人读名,指向对话区);决策卡**不走**这一支 —— 人要决定的常常就是件上那份稿。
- **第三个活动 ACT-WORK-05 差异评估已入库并全流程走查**(2026-08-27,`part-substitution-assessment@1.2.6`):
原 PN 与候选替代料**逐项差异评估** —— 比参数、判四态(MATCH / MISMATCH / UNKNOWN / CONDITIONAL)、
@@ -1388,6 +1399,667 @@
e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(卡会落到 token 主人的工作台、不续期;
服务账号 `client_credentials` 仍是正路)。
- **数据工单确认即入库**(2026-08-27 下午,用户:「数据工单里面填的数据应该是确认这个数据工单后
这些数据就应该入库了」):查下来草稿层通往正式表只有整单放行一道口子(`releaseWrite(taskId)`)——
W05 的 P-08/P-10 卡上写着「确认后即入库」,而人建好的主数据要等整条评估放行才进表,试跑单
(不落主数据)永远不进。**不能简单地「确认时 releaseWrite 那几张表再清草稿」**:引擎的 `mine:` /
`_rows` / 哨兵(`parts_profile_rows >= created`)、视图 `data-rows="mine:parts.profile"` 全靠草稿层
认「本单写下的行」,清掉就当场断。修法:`releaseWrite(taskId, { only, cause })` **范围放行** ——
只并这张卡提交闸盯的那几张表(`gateLedgers` / 绑的 `blocks`,与闸同一份名单),并过的行**留在
草稿层打 `landed` 记号**(`__draft.landed_at`):读面一字不变,整单放行不再写它、不抛事件、不做
冲突检查(`landedEarlierRows` 分开报);再写一笔记号擦掉、基线换成落地后的值。事件不指回本单
(它正等自己的人事件)。app 侧 `data-order-landing.ts` 在回传路由里、保存与回传落定后调它;
失败不拦人(痕上说清,放行时再并)。`createOnly` 与 `drop` 对本单已落地的行改口(别 drop ——
撤了读面少一行);数据域「在办草稿」不再数已落地的行。活动 1.4.8:两张数据工单的剧本改口
「已入正式表,别再写、别 drop」。测:ledger IT 3 条(范围/记号/再写/试跑)+ app 7 条 + 域 1 条 +
活动 1 条;e2e w05 数据工单交卷即验痕(`JCAI_E2E_LAND_MASTER=1` 时连正式表一起验)。
- **验收卡只许有一种样式;验收件题头改验收绿**(2026-08-27 下午,用户:「验收卡怎么有两种样式,
统一都使用评审放行与落账确认这一步派出的这个验收卡的样式;中央 html 模版区域如果是验收工单
把标题部分的红色改为和验收卡一样的绿色」):①审核卡(决策卡 `variant:'review'`)从前只换了头上
的字、身子还是 A/B 选项列表;现在照终局验收卡 `AcceptForm` 那一套摆(`ReviewForm`:请你验收 ·
验收工单入口 · 理由框 · [重做] … [确认通过]),机制不变 —— 通过交 approve 项,重做写了理由走
`custom:<理由>`(基座按字面点名对行)、没写走驳回项;判断抽成纯函数 `review-face.ts`(7 测)。
「验收工单」钮对没有工单行的审核卡也要有反应:`ViewRegion.locate` 找不到那张单就把待处理翻出来
(件在那一屏)。②W05 两张验收件(ACC1 差异评估结论审核 / ACC2 评审放行与落账确认)的 `--ledger`
由目标件的 #b23a2e 红改成对话区验收卡同一枚 #0aa294(`--st-collab`),题头渐变与徽章投影同改;
CONDITIONAL 状态徽标是语义色,保留原红。W05@1.4.9 已装;浏览器走查未做(需登录统一鉴权)。
- **S1 清单把人刚填的参数摆成缺口:示例引了原话,模型照抄示例**(2026-08-27 下午,用户:「第一步已经
补充了 XL2596S-5.0 使能阈值,当前这一步还要填一次?」):人在 P-08 件上填了使能阈值 2.5(已入库),
两分钟后 S1 清单上却有一行「XL2596S-5.0 使能阈值 · 原厂未披露」。查下来 S1 剧本写着
`input_item 如「XL2596S-5.0 使能阈值」`、说明书⑤卡骨架写着「原厂未披露的参数(如使能阈值)」——
模型按示例造行,不读现值(值就在 mine:/focus 里)。与 08-03「反例引了原话,模型照抄反例」同一课:
**示例也不引具体料号 + 参数名**。W05@1.4.10:示例改成占位形「<料号> <参数名>」,写明「先读现值,
人填了值的参数不是缺口、零行合法」;包内测钉死。顺带看见:压拍时 `focus:` 只带拍首那一步的 reads
(S0d_p10 那一拍连做 S1 的写与发卡,S1 声明的 parts.parameter 不在 focus 段,只靠 3000 字的 mine: 段)。
- **运营中心 · 活动模块接口落地(L1/L2/L3 十一条,全 `ops`)**(2026-08-27,`docs/API文档-活动模块.md` 已按契约回写):
契约 `packages/contracts/src/{activity-ops,api/activity-ops}.ts`;服务端住 app 层 `apps/api/src/app/activity-ops/*`
(纯函数:启动参数派生 / 五态映射 / 时间线 / 摘要 / 视图注入;raw SQL 存取;服务编排)+ `routes/activities.ts`,
写全走既有 port(`task.start` origin=ops / `task.control('terminate')` / `registry.setEnabled`)。
**设计端两条改名让位**:`GET/POST /api/gov/activities` → `/api/gov/activity-packages`(前端零改动,import 的是路由对象)。
迁移 0034(`sys.task.seq` 人读实例号 `INS-<seq>` + 按活动索引)/ 0035(`sys.activity_launch` 幂等键一句 SQL 判占、
`sys.activity_profile` 负责人/说明/图标/色相,seed 按岗位填三个真包,取不到留空)。
五态是对 task 的如实映射(`pending` = 建单未被推进器领到;`rejected` = 打回未重提交那一段)。
W05@1.5.0:四个启动字段加 `x-control`/`x-options`/`description`(combo 候选从正式台账现读),
全流程分支图作 `activity-overview` 沙箱视图(模版无脚本,报高脚本由出口注入,协议 `jcai:view-height`)。
前端 §10 对接另起一轮。测试:契约 7 + app 层 33 + W05 包内 +1。
- **差异评估结论审核件改版:一个候选一张表**(2026-08-27 傍晚,用户改了目标件
`docs/SS02-ACT-WORK-05-html/ACT-WORK-05-验收工单-01-差异评估结论审核.html`:「存在多个替代 PN 要对比
差异时看不清楚,现在将每个待选 PN 分开」):渲染器从前只认一层 `data-rows`,做不出「外层按候选复制、
里层只列它名下的明细」。补**嵌套行模版 + `data-by="子列[=父列]"` 父键过滤**(`view-render.ts`,里层先按
本行展开再填本行标量;4 测),它也能当查表用 —— 品牌不在 relation 上,`<span data-rows="parts.profile"
data-by="internal_pn=candidate_internal_pn">` 从整表挑那一颗料的行。件照目标件重搬:候选 PN 结论汇总
(一个候选一条;差异统计绑 `difference_summary`,S3 剧本本就让它写成「7 项比对:4 MATCH / …」)+
`<article data-rows>` 包 `<section class="cand">` 的分组明细(`<tbody data-by="substitution_relation_id">`);
判据四条照目标件改;ACC1_CSS 整段重搬(.cand*/.rec*/.stat*,CONDITIONAL 改蓝),主题色仍取卡的
#0aa294(目标件改版自己写的是 #16a34a)。W05@1.4.11。规范 §4 绑定表补 `data-by` 一行。
- **「库里明明有值,清单还要填一遍」第三次复现:根因不是措辞,是让模型宣布台账事实**(2026-08-27
傍晚,用户:「都已经出现几次这个问题了,自己找到根因并解决」):1.4.10 改了措辞照样复现 ——
这一单 XL2596S-5.0 已在库、三颗料 21 个参数全有值、说明书已是新版,模型仍写出
「使能阈值 · 原厂未披露」并在交代里说「原厂没披露」。三条线索:①S1 的「待确认」行是**模型对参数表
的一句断言**,基座没有任何读数核它;②说明书里「使能阈值」与「未披露 / UNKNOWN」同句出现三处,
其中一处是我 1.4.10 写的反例原话「使能阈值 · 原厂未披露」(08-03「反例一律不引原话」那课又犯一遍);
③S-FEEL 没有列表过滤,想拿判据核「gap 行数 = 未披露参数数」也写不出。修(W05@1.5.1):**S1 一行参数
都不写**(item_key 不许以 unknown_ 开头);件上加「候选参数现值」只读块 —— `<article data-rows=
"mine:sub_eval.pn_check">` 逐颗复制,里层 `parts.parameter` 按 `data-by="internal_pn"` 现读,人看得见
每个参数此刻的值;未披露的由 S3 判 UNKNOWN + 验证任务(本来就是它的活)。说明书全文不再出现
「使能阈值」(包内测钉死);S3 示例改「实测<参数名>」。设计律:**台账里有的事实,不许由模型在别处再宣布一遍。**
- **「你真的改好了吗」—— 改是改好了,库里没有**(2026-08-27 傍晚):新起的单 `loop_state` 里还是旧说明书。
查下来两条:①`sys.activity_package` 里 1.5.0 / 1.5.1 两行都是**另一个会话**在同一个 checkout 上
改出来的(activity-overview 视图 + 启动表单 x-control),各比我的安装早几分钟占了版本号 ——
我两次 `activities:install`(候选分组件 / S1 参数行退役)全是**同版重装 no-op**,脚本还打印
「活动包已装」;②`manifestOf` 每次读库,不是服务缓存。修:升 1.5.2 重装并**用 SQL 核库里的清单**
(查 JSON 文本别用 `\"` 手写转义 —— 第一次核出个假阴性);`InstallVerdict.stored`(契约,默认 true)
+ 安装脚本同版时改口「已是这一版,未重装」。规矩:**装完看库,不看脚本的话;两个会话同改一个包,
版本号先问库里最高的是几。**
- **「验收卡又变成需要你定」「差异明细两张表没渲染出来」—— 同一个根:稳定版后端跑的是早上的代码**
(2026-08-27 傍晚):`.stable/RELEASE` 是 `3171d8a`(11:53 构的),早于「审核卡摆成验收工单」那一提交,
更早于当天所有改动;它与开发后端(:8787)抢同一个库的租约,抢到哪一拍就用旧代码发那一拍的卡、
推那一拍的帧 —— 决策卡没 variant、帧里 `data-rows`/`data-by` 原样漏出(旧渲染器不认嵌套)。
修:`./stable.sh update`(稳定版现为 `7555337+未提交改动`,与开发一致);并把审核卡的面孔改成
**引擎按剧本定**(`faceOf`:点名的项优先,挂不到就按当下在办的那一步 → `cards.decide.variant`),
不再只靠模型给对 itemId(2 测)。排查法:先看 `.stable/RELEASE` 的 commit 与 `lsof -iTCP:8790` 的
进程启动时间 —— **两台后端一个库,先问另一台跑的是哪一版。**
- **「叫了人我都没填你就直接推进了」—— 两道结构闸**(2026-08-27 傍晚,用户:「不能再有叫了人又继续
自动推进的问题」):实测账 —— S1 的执行卡 17:48:00.2 发出,0.3 秒后投到的是**上一张(P-10)卡回传的
人事件**(回传落库那一刻已 wake、推进器读 card-reply 先跑了那一拍,事件晚 12 秒),`deliver` 照样
tick,再一拍模型读到事实行「这一步已经发过卡,人已经被问过了,照『人交回之后』办」,把没人答的 S1
收掉。①`task.deliver`:人事件带 `occurredAt`,任务在这条回传之后推进过就只记到达痕(stale)不推;
②`runTurn`:`cards.unanswered(taskId)` 非空这一拍不问模型(`human-tool.pendingCards` = 没办结工单 +
没作答且没被顶替的对话卡),谁叫醒都推不动 —— 这一条管住所有多余唤醒(另一台后端 / 手动 wake)。
5 测(task 3 + turn-runner 2)。两台后端都要装上:dev 自动重载,稳定版 `./stable.sh update`。
- **审核卡 = 终局验收卡的结构:判据表,不是参考信息**(2026-08-27 傍晚,用户:「给你说了要最后一个
评审放行与落账确认这个验收卡的样式和结构,没有参考信息这个东西,变成 L1、L2 这种东西」):
`decisionCardSchema.criteria`(可选)—— 任务详情读时对**没作答的 review 卡**现算同一摞判据
(`checks.of`,L5 不上卡,过契约 schema 归型),与终局验收卡同一段算法同一份读数;`ReviewForm` 摆
`Crit` 判据表 + 头上「n/m 达标」,卡上不再有参考信息 / AI 建议。答过的卡不带判据。
- **验收卡的条目 = 人工验收清单,人点「达标 / 不达标」,点全才能通过**(2026-08-27 傍晚,用户定,先只做
ACT-WORK-05):从前卡上摆的是系统判的判据(L1/L2…)让人签;用户定「不是系统判断的条目,是人要对这个
验收工单的内容进行审核需要的步骤,每份验收工单固定」。契约:`PlanStep.acceptanceChecklist`(剧本声明)→
`AcceptanceWorkOrder.checklist` / `DecisionCard.checklist`;回传 `checked`(落 `done.checked` /
`chosen.checked`);human-tool 回传校验:通过必须 `checked ⊇ checklist`(`checklist-incomplete`),审核卡只拦
「通过」选项。引擎:审核卡随面孔带清单(`faceOf`),终局验收卡在 submit 时从剧本取(声明了清单且不是审核步)。
前端 `Checklist`:序号格 + 右侧开关(**待确认灰 ↔ 已确认绿**,用户定;不叫「不达标」—— 没点过不是判了不合格),
头上「已确认 n/m」,确认通过灰到点全;答过的只读摆当时点的。W05@1.5.3:
S4_review 4 条、T_release 5 条(按两份目标件的【验收判据】写成人眼能核的动作)。E04 / I01 未动(用户定先不管)。
`decisionCardSchema.criteria`(上一轮加的)撤掉。测:card-rules 2 + turn-runner + review-face + 包内 1。
- **「标红的填了还是不让推进」:红带点的是「缺输入项」,不是补充内容**(2026-08-27 晚,用户实测):S1 清单
11 行全没有 `input_item`(必填的人读名列),模型这一拍只写了 status/detail/origin;件上红带的固定前缀写死
「补充内容没填」,人于是把 11 行的补充内容全填成「1」,闸照拦(它拦的是输入项)。病根:剧本只在括号里提过
一次「input_item 用人读名」,逐行做法里从没再说 —— 措辞的「从没说过」那一档。修(W05@1.5.5):剧本逐行写死
六列与每行的人读名(客户 / 客户需求 / 原 PN / 候选 N / 应用场景 / BOM 位 / 替代类型 / 规则版本 / 负责人期限 /
原 PN 资料版本)+ 发卡前自核 `_input_gap_missing = 0`;红带前缀改「必填格」不写死列名;包内测钉死。
顺带答用户「这些输入项库里拿不到吗」:输入项只是每行的名字(AI 该写),不是库里的数据;真要人填的只有
应用场景 / BOM 位两格 —— 它们不在任何表里(P-01 是本单新建的、P-09 BOM 没建模)。
- **进度条钉在 1/6 · 重做意见变成「驳回(不写具体意见)」**(2026-08-27 晚,用户实测两条):①`phaseProgress`
从前「一段里的项全收完才算过」,都在库时数据工单两项永远 Available,条钉死在第一段;改成「案子走到了哪一段」——
最后一个有项动过(Active / Completed / Terminated)的阶段,全收完就进下一段,没收完停那一段;一项没动过才退回
老规矩(3 测)。②模型把审核卡骨架里的 `allowCustom` 抄丢,引擎默认 false,前端写了意见的重做只能退回驳回项,
卡面摆的是选项原标签;现在审核卡一律收自定义意见(`allowCustom ?? reviewFace`),答过的卡摆**验收的话**
(确认通过 / 重做:<意见> / 重做(未写意见)),不摆选项标签。
- **「评审放行与落账确认」派了两次:先一张执行单,人答完才 submit 又一张验收单**(2026-08-27 晚,用户送来
5616e13d 那单):S5 在同一拍里被自动收步后模型接着被问,读到人工激活步的事实行「唯一合法的动作是先发那一张卡
(human/entry/decide)或立监听」,就给 T_release 发了一张执行工单;下一拍才 judge + submit。修三处:
①`isReleaseStep`(剧本声明了人工验收清单且不是审核步)—— `human` 挂到放行步当场拒并指向 submit;
②放行步的事实行只指向 submit(不再套执行步那句);③验收卡题目取案例模型里那一项的名字
(「评审放行与落账确认(ACT-GATE-03)」),「收口验收」只是兜底。W05@1.5.6 剧本补一句「不发执行工单」。
2 测 + 包内 1 测。
- **工作成果详情右上角只翻页,不翻份**(2026-08-27 晚,用户:「这个东西不是让你在整个工作成果里面切换,
是让你在单个工单有多个页面的情况下切换的」):08-23 加的「翻份」撤掉;右上角那对箭头只在这一份件里翻页
(一页也摆 1/1),换一份成果回列表点。
- **验收卡「又变成老的」:投影按标题里有没有「验收」二字挑面孔**(2026-08-27 晚,用户实测):
`chat-projection` 从前拿 `title.includes('验收')` 决定 card-sent 是判据卡(`criterion` → `AcceptForm`)
还是执行卡(`guidance` → 通用「派出工单」气泡),而同晚验收卡题目改成了案例模型项名
「评审放行与落账确认(ACT-GATE-03)」—— 四个字不在了,人工验收清单整张不见,摆出的是 11 条判据 +
「打开报告」。同文件上面几行早写过「标题措辞是给人看的、随时会改」。改认痕里的结构化键 `mode`
(发卡时随 payload 入痕),老痕没有它才退回标题。2 测;读时投影,在途单刷新即好;稳定版已更新。
- **「字已签下,正在落地」停住不动:迟到守卫把放行裁决也丢了**(2026-08-27 晚,用户送来 317c9124):
同晚加的「迟到的人事件不再推」(`lastProgressAt > occurredAt` → 只记痕不投)前提是**那一拍能从
留痕 / 卡库读到答复**(执行单填报、决策卡选项都如此);而放行裁决只走事件这一条路 —— loop 的
`onEvent` 把 verdict 落进在途验收信封。实测账:签字 04.129 落库 → `wake` → 推进器抢先一拍
(在途验收没裁决原地返回,但 lastProgressAt 挪到 04.483)→ 事件 05.774 才到 → 判 stale 丢掉,
再没有任何事件叫醒它。修:`payload.mode === 'acceptance'` 的人事件不进迟到守卫(认结构化键);
1 测。那一单以「删 event_consumption 行 + 投递行翻回 pending」让派发器按新路重投 → released、
合并 24 行、发口 assessment-released。**两台后端都装上再重投** —— 稳定版的派发器同样会抢这条。
- **P-10 的卡对着 P-08 的件:画面跟着这一拍发出去的卡走**(2026-08-27 晚,用户送来 3ef3fc3e:
「当前这一步不是应该给我推 P-10 的数据工单吗?怎么还是推送的芯片主数据创建」):模型没收掉
S0d_p08_master(剧本两处白纸黑字「先 finish 再发下一张」,措辞治不了)就发了 S0d_p10 的数据工单,
两步同时 Active,推帧按「第一步没干完的」取了 P-08 的件 —— 人拿着 P-10 的卡,屏上没有资料表可填,
提交闸拦「缺资料」。08-23 那刀(按 currentRuntime 重算)治不了两人环项相邻的形状(debt 早记了)。
结构治:`applyAction` 的 human / entry / decide 三条路把 `issuedFor`(卡是哪一步的)交回拍内,
推帧时那一步声明了画面就推它,压过别的规则;没发卡的拍照旧。1 测;debt 销账。
在途那单以「task_view.template 改成 p10-document-create + `views.repush`」救活(tsx 起容器跑脚本,
`-e` 不支持顶层 await,要写成 .mts 文件)。
**根治**(用户:「根因修了吗?不要只修表象」):病根是**这一步办完没办完是引擎手里现成的事实
(卡答了、数据入库了),却让模型去记得 finish**。①引擎:人工激活的项在 `worldTouched` 之后照常求
doneWhen(此前一律跳过,怕替人签字 —— 门槛保留:卡没发过不收),拍首扫一遍、**拍尾再扫一遍**
(人答卡那一拍模型写完东西 doneWhen 才真;刚发卡的项排除;submit 拍不扫免擦信封);
②W05@1.5.8:P-08 / P-10 各给一条只读入库事实的 doneWhen(要建的每颗料 = absent + created,
主数据/参数/资料都落了、必填不缺、逐颗对账),S1 哨兵不再等模型写 `created` 旗(那是「机检只读
基座数」的哨兵形);剧本里「顺序不能反」改口「引擎也会自己收」。现有 E04 / I01 / W05 没有任何
人工激活项声明过 doneWhen → 零回归。3 引擎测 + 包内 220 测;规范 §4.4 / 建模纪律 3、4 改口。
e2e w05 这轮没跑(用户的测试台占着,起单会顶掉他的单)。
- **工作区正文里的占位上下居中**(2026-08-27 晚,用户:「AI 推进中 / 这单已收尾 / 还没有往来记录 …居中显示」):
`.jc-empty` 早写了 `margin:auto`,可它住在自绘滚动区的视口 `.wf-body`(块级)里,纵向 auto 边距算 0,
全贴顶边。与 08-x 治五模块空态同一招:`.wf-body:has(> .jc-empty / > .jc-working)` 才切成纵向 flex,
件 / 日志 / 往来记录这些正文照旧从顶往下排。浏览器实证「这单已收尾」「还没有往来记录」两屏居中。
- **两颗一模一样的输入电压范围判成 MISMATCH:客户约束一票否决**(2026-08-27 晚,用户送来 0bddd862):
客户写「输入电压在 45V 以内」→ 模型译成 requirement `>= 45`,原 PN 与候选上限都是 40V;`judgePair` 只看候选
过不过客户线,不看原 PN 自己过不过 —— 需求边界与在用料的差距记在候选头上,硬约束 MISMATCH → 整颗不推荐。
差异评估比的是「换上去会不会比现在差」:工具 `compare-parameters` 升 1.1.0 —— 候选与原 PN 相等 / 盖住、
且客户线原 PN 也过不去 → 仍按比对结果判(MATCH,风险中),备注点名「需求边界,请回头核对需求」;
候选更窄且过不了线仍 MISMATCH(3 形有测)。工具注册一名一行、upsert 换版本,handler 在进程内按名绑,
装完稳定版重建即生效;在途单要人在审核卡上「重做」并写明意见才会重比。
- **差异比对的入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的
harness 肯定有问题,要让它认真比对每一条,然后再输出结论」):同一单痕里模型念出「输入上限 32V 低于客户 40V」——
那是 S3 剧本示例的原话,不是这单的数;工具入参只存哈希,喂了哪几对值事后查不到;写回时还把工具的 `equal`
改成 `less`。两头都靠模型抄、两头都没有基座核。改三层:①工具运行时把 `taskId / activityId` 交给处理器
(`ToolHandler` 入参加两个可选键);②`compare-parameters` 1.2.0:给 `originalInternalPn + candidates[].
candidateInternalPn(+ requirements)`,参数行从 `parts.parameter`、封装 / 生命周期从 `parts.profile`、证据引用从
`parts.document` 由工具现读任务视图(本单草稿也算),出参每项带原值与证据,写盘照抄;老形状(自带 pairs)照接,
试运行走它;container 用 `compareParametersWith(ledger)` 顶掉静态名单那份;③≤800 字的工具入参整份进 `result`
痕(审得了才谈得上认真);W05 S3 第一拍只报料号与约束、示例只留占位形,第三拍 compare_result 也照抄;
S4 重比只改 requirements。13 工具测 + 包内 89。**基座能读到的事实,不让模型转述给工具。**
- **「下限~上限」一律是范围:解析器补齐写法、出参摆出读法**(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型
搞不清楚,harness 里强调一下是范围值」):实测解析器真有洞 —— 单位写进值里(`4.5V~40V` / `-40℃~+85℃`)被读成
单个数,Unicode 减号、`…`、`≥45 / 40 以内` 读成文本。工具 1.3.0:分隔符认 ~ ~ - – — … 至 到 to、单位可夹在值里、
单边界读成半开范围;出参每项 `readAs:{original,candidate}` 写明「范围 4.5~40 / 定值 5 / 下界 ≥ 45 / 文本」——
比对依据可见,模型照它写影响、不自己重新解释值;契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,
不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。W05 升版装上。3 测(11+7 种写法)。
- **AI 把原 PN 的规格书填到了候选料号头上:人填的表要声明,判据不摆给还欠卡的那一步**(2026-08-28
柴浩实测单 6ddcecf8,连撞五拍熔断):P-10 资料记录的标题 / 版本 / 链接与台账里 `LM2596S-5.0` 那一行**逐字相同**
—— 不是幻觉,是「手边有一行看着对的就拿来用」(那一步 reads 摊开了 `parts.document`,视野里唯一一份真资料
就是原 PN 的)。三层合谋:①把 P-08 的回卡认成 P-10 的(P-10 的卡从没发过);②我 08-27 给这一步加的 doneWhen
把「资料必填不缺」摆成目标、后面还跟「成立了就 finish」,**与同一份提示词里「唯一合法的动作是先发那一张卡」
直接打架**;③它顺手把 `exists_status` 改成 created,按剧本发卡名单自己空了 —— 一个合法动作都不剩。
止血两刀:**①`PlanStep.humanWrites`**(契约)—— 声明的台账 `write` 当场拒并指路「去发那张卡,值由人填」,
`drop` 不拦(写错了得撤得掉);判法认点名的项、点不出退回当下在办的那一步(压拍时骨架写在上一步名下正好放行)。
**②还欠一张卡的那一步不摆 doneWhen** —— 判据同时是摆在模型眼前的目标,而它此刻只许发卡。
W05@1.6.5 声明 `humanWrites: ['parts.document']` 并从 writes 去掉。4 测(墙 / 放行 / drop / 声明)。
**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑那个判据。**
用户同时点出更大的一条(记 debt,未做):**存在性核对 + 派数据工单是确定性流程,根本不该交给模型** ——
平台缺「确定性步骤」(纯工具只读、判定表不写台账、doneWhen 只收步),所以只能写进 guide;
引擎三样能力都有(taskView / draftWrite / issueExecution),缺的只是声明面。
- **确定性步骤已出设计,未动代码**(2026-08-28,ADR-014):盘完 W05 九步 ——
**三步整个确定**(S0_bind / S0d_p10 / S5)、四步八成确定、只有 S3 是 agent 主场;一单十来拍里七八拍在做确定的活。
agent 真正不可替代的只有四类:自然语言→结构化 · 读人的意见落到具体行 · 写给人看的话 · 非结构化抽取(实跑中一次没发生)。
设计要点:**声明式配方进 manifest,不执行包代码**(ADR-004「不执行代码即知包为何物」;artifact 至今零执行,
不为这个功能开沙箱面);三个动词 `project` / `card` / `say`,条件复用 S-FEEL、聚合复用基座读数,不发明第二套求值;
`auto` 是「问模型之前先把确定的事做了」,作用域到交出控制权为止;**一步要么确定要么不是**(判断点先切出去,
不做「一半 auto」);出错交人不重试不退回模型;静态审把地址/列名/S-FEEL 在装的时候审死。
**回溯验算:已记录的八条事故落在会被 auto 掉的三步半上**(编号抄别单 / 判在库判错 / 派给在库的料 /
没写骨架就发卡 / 清单漏列 / 库里有值当缺口 / 抄别的料的资料 / 撞墙五拍熔断)。
立场要改口:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。**
五条待定见 ADR §11(决策卡给不给配方 / 工具串接 / humanWrites 细到列 / S2 与比对工具的 join 重复 / T_release 第三条裁量该删)。
- **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行
实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 +
参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。
**取数归活动声明、执行归基座**:契约新增 `viewTemplates[].data`(`case{ledger,key,prefix}` + `rows[{as,ledger,by,not,orderBy}]`,
`from` 只能指 `case` 或前面的 `<as>.<列>`),`view-scope.ts` 按声明逐表等值读正式表、摊成扁平作用域
(`<as>` / `<as>.<列>` / `_rows` / tally 计数 / `case:*` / `board:state` 三态)交 `renderView`,`/view` 出口照旧注入报高脚本。
案号 = 前缀 + 开案时刻 14 位(`case-stamp.ts`,与引擎注给模型铸主键的 `case:openedAt` 同一函数 —— 实测 INS-20
`13:51:03Z` ↔ `W05-20260827215103` 一字不差);放行后草稿层已清空,所以只能这么圈。`store.latestReleased`。
W05@1.6.0(库里核过带 `data` 11 条);`views/overview.ts` 删除(docs 里的源文件不动)。测:契约 3 + view-scope 9 +
service 3 + store 1 + 包内 4;真库渲染 `v-ready`、INS-21 / `W05-20260827215148`。欠账见 debt(挑单 / 行级来源 /
`vtok` 三份 / `data` 装包静态审)。
- **确定性步骤已落地**(2026-08-29,ADR-014):`PlanStep.auto` 声明式配方,五个动词
(`project` / `analyze` / `card` / `submit` / `say`)、四种地址、嵌套两层封顶;引擎在拍首跑,
**发了卡就收拍(零 token)**,有 `guide` 的照常问模型。`analyze` 是**一次结构化 AI 调用** ——
系统组装入参、声明出参 schema、系统落表,**不给工具不给记忆不多轮**。
三条纪律进了规范 §4.6:**行由确定的那一侧决定不由 AI 决定** / 判据不许编数不出来的数 /
每一笔之前刷读数。静态审在装包时把地址、列名、模型名、视图、submit 的验收清单逐条核死。
配套四件:`data-options`(下拉选项从台账现读)· 件上保存后 `wake`(「保存即铺格」)·
审核卡放宽到单选项 · 卡答复即收步。**E04 / I01 / W05 一个字没改** —— 它们的剧本里没有 `auto`。
- **ACT-WORK-05 从『什么都丢给 AI』重写成『系统确定性取数 → AI 只在真需要判断处出场一次』**
(2026-08-29/30,`part-substitution-assessment@2.1.6`,design spec
`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`,用户原话立场:「不是把
所有东西都丢给 ai 来处理…一定是系统先确定性的从数据库中取出数据后交给 ai 分析」)。
九步砍成**四步**(S1_inputs 核对入参与补数据 → S2_assess **整个活动唯一一次** `analyze` →
S3_review 结论审核 → T_release 落账放行),**全程零 `guide`**,行为整体搬进 `PlanStep.auto`
声明式配方(ADR-014 第一个真实用户);零工具、零技能,两枚旧工具与两张 DMN 一并退役 ——
规则只许有一处真源,活在配方的 `when` 链里。新建两张类型字典
(`parts.chip_type`/`parts.type_param`):按芯片类型(不是按料号)标一次核心参数字段与
硬软约束,「两颗料必须用同一套参数名」从此是**结构保证**,原先靠测三次都没拦住的
自指引用墙退休。三张件(数据工单 · 结论审核 · 落账确认),数据工单按缺口条件发卡,
零卡直通是常态路径之一。
**e2e 五案例的真实战果**(2026-08-30,连续多轮真跑,详见
`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-11-report.md`):候选缺库·新建类型 /
都在库·零卡直分析 / 原PN缺库三例 `released`、**被拒 0**、**模型调用恰 1 次**、发口
`assessment-released` 全对——**「候选缺库·新建类型」这条记录后来被全分支终审推翻**
(见下方「全分支终审」条):那次 `released` 靠脚本走 API **显式传 `type_id:'90'`**,
没有走真人在浏览器里点「新建类型」那条路;`e2e-a2.mjs` 自己的注释原话是
「这条路径连真人在浏览器里都走不通」。**该走的那条路当时藏着 Critical 1**(见下方条目),
这里的「released」只证明了 API 直传能通,不证明「新建类型端到端通了」。
类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁
(`prune` 与「数据工单确认即入库」的架构性冲突:人先交一次、字段已落地,再改类型选择,
`prune` 想撤已落地的陈旧行被显式挡住,两条机制各自都对、合起来死锁);两颗都缺库那例
卡在「决策卡答完之后不再推进」,`advancing_until` 反复核过是空、租约与陈旧进程两种假说
均已排除,**真因未定位**,如实记录未解决,不强装成功。**`judgement_class` 逐行核对通过**
(7/7:使能阈值 / 工作温度 / 开关频率 / 输入电压范围 / 输出电压 / 输出电流 / 静态电流,
AI 判的 `difference_status` 与规则链算出的 `judgement_class` 逐行对上,`risk_level`
联动也对)—— 这条验证之所以要点名贴出来,是因为它曾经**逐行全错**。
**地基缺陷**:活动清单存进 `sys.activity_package.manifest` 这个 jsonb 列后,PostgreSQL
按「先短后长、同长字典序」重排了所有嵌套对象的键,而 `project`/`analyze` 的 `fields`
原来靠 `Object.entries` **声明顺序**求值(后一字段靠 `ctx.computed` 看前一字段已算出的
值)——「声明序 = 依赖序」这条从没写下来的假设一进数据库就被打破,且**不报错**、
隔离测试也复现不出来(它直接读 `dist/manifest.json`,不经数据库往返),只有真跑 e2e
才现形。W05 实证:合成列 `judgement_class` 被排到它依赖的 `difference_status` 前面,
读到 `undefined`,七行**全部**误判(`difference_status` 本身走 `lookup` 不依赖字段序,
所以是对的 ——「一个对一个全错」的怪现象由此而来)。**修法是把字段求值从『声明序』
改成『依赖拓扑排序』**(Kahn 算法,新增 `packages/utils` 的 `topoOrder`,契约纯新增);
真正的环在装包静态审当场拒装,不兜底、不静默放行。四条新纪律入规范 §4.6(六条→十条):
`fields` 顺序不可依赖拓扑排序之外的任何假设 / 跨笔顺序仍要人自己排(一笔读的是另一笔
写出来的数就得排在后面)/ 方向相反的依赖拆两笔写、中间夹子表 / `gateLedgers` 同时管
「闸要求非空」与「确认时并进正式表」两件事,漏想一半就会悬空引用或永久卡死。
`pnpm gates` 退出码 0(2447 passed | 34 skipped)。
- **W05 数据工单换版面已退回**(2026-08-31 用户:「改了 ui 之后这个版本我感觉有点问题」):
`report.ts` / `target-style.ts` / `activity.test.ts` 三个文件**逐字节恢复**到换版面之前
(45cec82 的样子),包版本 **2.3.0**(内容是旧版面 —— `store.manifest` 不传版本时取
**最近安装**的那一条,不是版本号最大的,所以退源码必须配一个新号装进去才真生效)。
**换版面那一版原样活在分支 `feat/w05-view-swap`**(HEAD `1373eb4`),随时切得回去;
下面这条记的是它的账,留着当索引 —— 里面三条渲染机制的实锚(宿主闭合标签 / 行内
class-of 只认行作用域 / data-by 回落)与平台侧探针测试**不随退回作废**,它们钉的是
平台行为,测试与目标件 html 都还在主支上。
- **(已退回,见上条)W05 数据工单换版面 + 两条渲染机制的实锚**(2026-08-30,`part-substitution-assessment@2.2.2`,
用户给了新目标件 `docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html`):
`input-supplement` 整张照它重排 —— **两条并列轨道**(每颗料一条:档案卡 + 主数据 + 类型选择)
+ `<details>` 新增类型面板(chip_type / type_param 两张表原样搬入,独立「核心参数字段」区退役)
+ 核心参数值**瓷贴单块平铺**;绑定面一列不差(四张台账 / 四道闸 / 两处加行 / 终审 C1 的
disabled type_id / 主键钥匙)。目标件脚本驱动的状态全部无脚本翻译:保存即铺格顶掉「选类型
即时加载」、details 顶掉 `.newtype.show`、步骤圆点按基座数点亮、gateprog 进度条如实退掉
(分母不是基座现有的数)。**三条机制账**,全有测钉死:
①**`data-rows` 宿主的闭合标签就是行模版的截刀** —— ROWS 正则按宿主自己的闭合标签非贪婪
截取,`<div class="lanes">` 被行模版里第一个 `</div>` 截半,第二行嵌进第一行没闭合的标签里
档案卡当场串行(A2 立的「行容器用行内不会再出现的标签」说的正是**宿主**,它自己就是
`<article>` 包 `<section>`);防再犯钉用**配平判法**(「不含闭合标签」是空话 —— 非贪婪截取
本来就停在闭合标签之前,那条断言永远不会红);
②**行内 `data-class-of` 只认行作用域**(renderRows 末段 renderTemplate 拿的是行对象),
作用域级读数在行内投不出 —— 圆点的投影挂在行外的 `.pj` 包裹层,后代选择器点亮;
③**`data-by` 行缺键落回外层同名字段**(终审 I3)真咬人:XL 没选类型,`sub_eval.case`
摊平的 `chip_type_id` 顶进来,查名 pill 摆出它没有的类型 —— 守卫认 `selectCurrent` 按
**这一行自己的值**写下的 `option[selected]`,行里没真选上名字不摆(判断全在样式表)。
同批逮到并修死:profile 的编辑闸在第一版 body 里**整个漏掉**(宿主在而闸不在,XL 必填
永远补不上 —— 「四道闸」旧断言只查 data-edit 宿主没查 [data-edit-for] 闸,补强)。
另一半探针钉进平台测试:嵌套 data-rows 在唯一 data-edit 宿主里**必然把一颗料的格子并成
一条记录**(rowOf 认宿主直接子元素;真实 renderView+collect 实测,
`view-container-collect-div-rows.test.ts`)—— 步骤③不按轨道分两栏是机制不是偏好。
**验收**:活动自测 62 → 74(新钉逐条变异验证);`pnpm gates` 退出码 0(2497 测);
2.2.0/2.2.1/2.2.2 三连装库 SQL 逐次核过;浏览器全流程真人路 —— 起单 → 两轨道核对 →
保存即铺格(XL 七格当场铺出)→ 填参数 → 确认写入办结 → S2 分析推进;新建类型走种子行
续号 `03`、DC-DC 原行未被覆盖、下拉即时长出;类型不一致护栏真亮 v-2 再熄灭。
终审评审 0 Critical / 0 Important / 1 Minor(注释漂移,已修)。跑完 reset-runs,
主数据与活动缓存零残留。
- **W05 三张工单件视图层按 C 端重设计**(2026-08-31 用户定:「不要后台管理端的样子,
要给客户用的 C 端界面;可加主题 SVG 与动画;只保留功能,视图层可以推翻重新设计」,
`part-substitution-assessment@2.6.0`):「精密检测报告」语言 —— 深色电路板夜景 hero
三张各一幕(建档台:两颗料的芯片铭牌由 `mine:sub_eval.part` 铺行、不在库虚线描边 /
对决量尺:原 PN→候选 PN + 推荐等级徽章 / 落账席:慢转印章环 + 一句话结论),正文瓷白
报告版面(丝印小标 / 比对光谱分布带 / 落账三卡大数);动画全 transform/opacity、
reduced-motion 全静;验收两件仍是验收绿 #0aa294。**功能绑定逐项保留**:可编辑表 DOM
结构逐字节不动(收值链路按结构认元素),机制规则(计数器/种子行显隐/gate-bar :has/
overflow-x)逐字保留仍被测试钉着。设计系统收进 `views/style.ts`,`target-style.ts`
退役(目标件时代的锚),report.ts 的 vtok/badgeCss 收归 badge.ts(销一笔 debt)。
离线预览自审(真 renderView + 造景数据,不碰测试台)逮到并修掉三洞:kv 占位破折号
连嵌套徽标一起吃(改只认直接子级)、光谱条里渗计数字、加行钮没 base display:none。
104 包内测 + 2516 全仓绿,两条新锚变异验证过,2.6.0 装库 SQL 核过。
(2.5.0 是同日另一会话的启动表单芯片类型联动 —— 两会话同 checkout,版本号先问库。)
- **W05 数据工单三病一改:档案卡上桌,prune 跳过已落地**(2026-08-31 用户实测三连 +
「设计只是头上加了动画」,`part-substitution-assessment@2.7.0`):
①「类型自己给上了」= data-by 行缺键落回外层(debt I3 原样咬人)—— 查名 pill 摆出案卷锚
(原 PN)的类型;治:守卫认 `selectCurrent` 按**行作用域**写的 `option[selected]`,
真选上才摆名、没选摆「待选择」胶囊。**守卫必须单层 `:has`** —— `:has()` 里嵌 `:has()`
是非法选择器,整条被浏览器静默丢弃(第一版就栽在这,预览截图逮回),挂共同祖先上写。
②「品牌…点保存根本不会保存」= 块① 行源是 `sub_eval.part` 而那张案卷表没有主数据列,
`renderTemplate` 只认行作用域 —— 保存真落草稿、显示与孪生件永远空白(四步重写后的潜伏缺陷,
真人一填才现形;e2e 走 API、旧浏览器走查在 swap 版上,两条线都照不到)。治:行源改
`mine:parts.profile` 本身(S1 ④ prune:true 恒有两行),行里天生有全部列。
③「改回同一类型,下面还是两个类型」= ②③ 自投影为绕「已落地行撤不掉」的死锁特意不带
prune,没落地的陈旧行也跟着积。治在平台:`draftDrop` 加配方专用旗 `skipLanded`
(跳过并出声「N 行已随数据工单入库、这一笔不清它」,不炸),②③ 恢复 prune:true ——
H35 案例 2 不再死锁,debt「prune 与确认即入库的架构性冲突」销账。quiet 拍仍不撤行(F3),
清理落在每次确认工单那一拍。
④设计:块① 从 11 列横滑表推翻成**电路板工作台** —— 深色板底上两张可就地编辑的芯片
档案卡(SOIC 封装 SVG + 角色/在库徽 + 类型选择行 + 双栏主数据格),编辑态整卡变表单;
三级占位(必填红/建议橘)上卡与参数值表(徽标包装的必填格用单层 `:has` 认裸 class 补胶囊);
参数值表所属料号列加角色徽。测:107 包内(守卫/行源/嵌套零 data-col 三锚变异验证)+
auto-runner skipLanded(变异验证)+ 2520 全仓;2.7.0 装库 SQL 核过。
另:1838474「在库」是真的 —— 用户此前某单 land_master 落过它(正式表 chip_type_id 为空)。
- **W05 第二轮:比对基准合一卡 + `prune:'live'` + 蓝图板底**(2026-08-31 用户:「类型和字段
合并成一个卡…两个芯片同类型就只显示一种,现在只变多不变少」「卡片容器黑色和页面不搭」,
`part-substitution-assessment@2.8.1`):①类型+字段并成**比对基准卡**(只读、跟手)——
不列 mine: 镜像(积旧选择,撤行只在确认拍跑),按案卷锚嵌套查字典,保存即换脸、同类型
恰显一种;新建类型收进 details 抽屉(零脚本),编辑表原样在抽屉里。②契约新增
`prune:'live'`(quiet 拍也撤,**只给人加不了行的表**用 —— F3 防「人刚加的行被撤」,
这种表没有该风险;有加行钮的表永远不许用):W05 参数骨架改 'live',改类型参数行跟手换;
static-audit 盖住 'live',规范 §4.6 纪律 5 补反面特例。③档案卡板底深色→**浅色蓝图**
(强调色晕染+淡格线),整页只剩 hero 一处深色。锚测各变异验证;2.8.1 装库核过;
:8787 watcher 又断,应急手起 node dist/main.js(14:41)。
- **W05 第三轮:数据工单去表格化 —— 翻面芯片卡 + 平台多宿主收值**(2026-08-31 用户:
「界面东西太多做成几个区域;类型与字段做成和芯片卡差不多的卡片;芯片卡与核心参数值
合并,点击翻转、背面写参数、悬浮看得出能翻;这个界面都不再需要表格展示数据」,
`part-substitution-assessment@2.9.0`):主画面零表格 —— 基准卡瓷贴化(类型丸+字段
瓷贴),芯片卡纯 CSS 翻面(checkbox+:has 零脚本,悬浮提示丸+侧倾;主数据编辑锁正面、
参数编辑自动翻背面),背面参数瓷贴就地可改;说明四条收折叠;表格只剩新建类型抽屉
(加行机制认 tr)。**平台扩面:ui-kit ViewContainer 多宿主收值** —— 同一地址
querySelectorAll 全数进来,**格子只归最近的 data-edit 祖先**(嵌套异地址排除;不带
data-edit 的嵌套 data-rows 行为原样,两条 collect 旧钉测不动);addRow 仍单宿主。
三脚各留一条:CSS 注释写「data-col=」原文会踩翻别件的字面断言(选择器与注释都不许);
`.flip-cue i` 特异度压过 `.cue-back{display:none}`;host 计数断言别拿渲染后的数去数
模版源码。ui-kit multihost 3 测(变异验证)+ 包内 110 + 2528 全仓;2.9.0 装库核过;
浏览器实测点卡真翻、参数编辑双卡自动翻背成表单。
- **W05 第四轮九连改:笔↔软盘卡角编辑 + 只选不建**(2026-08-31 用户一次点齐九件,
`part-substitution-assessment@2.10.0`):①编辑闸两行退役 → 卡右上角笔(开当前面)↔
软盘(存)+叉(弃),按钮即 data-edit-open/save/cancel 触发器,按面显隐全在样式表;
②ui-kit `mirrorTwins` 修「保存一瞬间下拉变数字」—— 镜选项文案不镜 value,顺手把
selected 拨到人选项(守卫 CSS 认它);③渲染器 twinInputs 认 `data-ph` 落 placeholder;
④参数说明空着什么都不摆;⑤芯片档案区提到比对基准前;⑥**新建芯片类型整条路移除**
(只选已有):抽屉/种子行/Critical-1 修法/thead 回归网退役,S1 配方八笔→六笔(②③
自投影删),gateLedgers 收窄 profile+parameter,e2e 案例 0 改选既有 type_1,七条退役
钉测 it.skip 留墓志铭;⑦题头回原目标件浅蓝渐变(hd-light,夜景只留验收两件);
⑧对话区执行卡不摆「参考信息」(basis 随卡数据照旧);⑨比对基准降噪:字段丸 +
说明悬浮气泡。2525 全仓四门绿,2.10.0 装库六探针核过,:8787 重启载入新渲染器。
- **W05 第五轮:按卡独立编辑 + 顶条合并点名**(2026-08-31 用户四连,
`part-substitution-assessment@2.11.0`):①「不要做联动」—— ui-kit 新增 `scopedTargets`:
开合作用域跟着触发钮走(钮在本地址宿主的行里→只这一行;钮在别的宿主行里→行内的
本地址宿主;外部闸→全宿主老行为),收值仍按地址全量、「只写真改了的行」服务端把关;
编辑态 CSS 全改按卡(.dossier.on),同地址同时只开一张卡(快照键按地址,双开互踩)。
②逐块「还差 N 行必填」红带撤下,_missing/_missing_keys 并进顶条「还差什么」两行子行
(v-0 自收)。③比对基准/芯片档案区序换回(基准在前)。④比对基准换层叠双封装 SVG
(t-ghost 淡剪影 —— 一类而非一颗)。ui-kit 35 测 + 包内 105 + 2528 全仓;2.11.0 装库
五探针核过;浏览器实证单卡独立翻背编辑。
- **W05 第六轮:确认闸条件档 + 量高可缩 + 互换动画**(2026-08-31 用户三连,
`part-substitution-assessment@2.12.0`):①「类型不一致还能确认」—— 平台加**确认闸
条件档**:配方 card 声明 `gateWhen`(S-FEEL 只读基座读数)+`gateWhenSay`,受理回传前
`submitGate` 按声明表现读 rows/missing/tally/distinct 现算,为假 400 用发卡人那句话;
静态审同 when 一套;评估不出的条件不成墙。W05 钉 `parts_profile_distinct_chip_type_id
<= 1`(钉 profile 本表零滞后 —— 钉 sub_eval.part 要等配方 tick 会误拦刚存完就确认的人),
profile.chip_type_id 加 distinct。②「底部没东西还能滑一截」—— ViewContainer 量高
documentElement.scrollHeight 以 iframe 高为下限,**只涨不缩**;改 body 优先(margin 0
的 body 才是内容自己的高)。③卡间动画换「可替换」语义:对向双信号点互穿(卡后走、
缝里现身)+ 中央 ⇄ 徽呼吸。2531 全仓;2.12.0 装库核过;:8787 重建重启。
- **W05 互换动画第二稿:与卡同级的实体动画道**(2026-08-31 用户:「不要做到背景板上会被
卡片覆盖,和卡片做到一级,压缩一点卡片」,`part-substitution-assessment@2.12.1`):
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
- **复核轮审核人说了算 —— AI 不许拿硬规矩顶回去**(2026-09-01 用户第二次实测:「重做还是没有
修改掉内容」):他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 于是搬出
第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去 ——
`evidence_note` 白纸黑字:「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。
**行的范围这次是对的**(只把那一条交给 AI、只写回那一条,其余六条一个字没动),错在**谁说了算**。
两处想岔了:①那条规矩管的是**它自己判**的时候别被措辞带偏,不是用来否决审核人的;
②方向也反了 —— 那条守的是「不许把不满足说成满足」(危险方向),而他要的是**收紧**结论;
③更根本的是:审核人在件上**本来就能亲手改任何一条四态**,AI 在重判这条路上顶回去,
只是让同一件事在两条路上给出相反的答案。
改:复核指令明写这条覆盖 —— **他点名要什么结论就落什么**(「不匹配」= mismatch、「有条件」=
conditional…,不要跟他讲道理、不要顶回去);落的同时 `evidence_note` 记两句
「按谁的意见」+「数据面是什么」(例:「按审核人意见调整为 mismatch:<原话>。数据面:两侧均为 5mA。」)
—— 下一个人回看时要能一眼分清**这是数据判出来的**还是**这是人定的**;
他只是提疑问 / 要补依据的,照旧由 AI 复核后自判。
e2e 案例 0 的打回意见同批换成**点名要结论**那一种,新断言逐字核那一行真变成了他要的值
(「打回点名要的结论真落下去了(使能阈值.difference_status = conditional)」)。
**同批把这一轮的交代摆进对话区**(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口
展示出来,不然都不知道发生了什么」):复核完说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,
摆的是**件上那一行自己的话**,不另编一句(对话区与件上说的是同一件事,就该是同一句话)。
**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 `say` 要等下一拍才跑:
原来那句「差异结论已送审,请核对」实测正是在**人答完之后**才出现的,而且它与卡自己的 `ask`
本来就是同一件事,一并退役。
- **落账席那枚印章把旁边的结论压住了**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」,
`@2.24.0`):**在浏览器里量出来的账** —— 徽章那一坨 `.seal-core` **139px**,而印章圈定死 **112px**,
溢出 26px;右边那句一句话结论从 603px 起,正好被压住。病根是徽章按自己的字宽撑开、
又带着 `white-space:nowrap` 缩不了,而圈是定死的圆(`place-items:center` 只会让它对称地溢出去)。
治法两条一起:核心不许超过圈的内径(`max-width:calc(100% - 16px)`)、圈里那一枚字号收小并
**允许折行**(以后有更长的等级名就两行,圆里两行照样是圆)。改完再量:核心 94px、圈内余 9px、
「有条件推荐」一行放得下、结论那段完全露出来。**视觉的账在真 DOM 上量,不在脑子里推**
—— 离线渲一份真件挂本地服务、浏览器里 `getBoundingClientRect` 取数,前后各一次。
- **「最后落账的步骤直接没了」= 单子先被烧死了**(2026-09-01 用户送来的 `INS-80`,终态 `exhausted`):
他在结论审核上连打回四次,每打回一次配方就重发一张审核卡、每张 +1 `humanCalls`,第八次触顶 ——
放行那一步再没机会出现。屏上看着像「那一步没了」,实际是**预算见底**。两处一起治:
①**预算提高**(用户当场定):8 → 24(授权面与资源面同一个数,测里改成「两处一致 + 下限 12」,
不再把某个具体数字钉死)。**这是配置不是机制** —— 机制上的正解是「人自己打回引出的那张卡
不算打扰」(他不是被打扰,是他叫的),记在 debt。
②**「一条都没点到」不再整表重判**(当天上线、当天撤):当时想的是「说的是整批」,
而他那句实际是 **「收口结论里面的推荐理由改为非常不推荐」** —— 说的根本不是某条参数,
是收口那四格(本来就能在件上直接改)。整表重判的下场:AI 拿这句话把 **5 条参数**的结论
改成「不匹配」,每条依据都写着「按审核人意见调整为不匹配:推荐理由改为非常不推荐」——
**一句与它们无关的话,改了五行**。现在点不到名就**一条都不改**,并照实说:
「你这句意见我没能对上具体哪一条参数,这一轮一条都没改;要重判某一条请写上参数名,
收口那四格在件上直接改就行。」—— **「猜他指的是全部」比「承认没对上」贵得多。**
- **「怎么还有重做??你没改上吗?」—— 手写字段清单第三次把值扔了**(2026-09-01):
上一条算对了、release 转对了、契约也声明了,而**容器那个显式对象**只转它点到名的那几样,
`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。**同一处已经吃过三次**:
①`fields` 写死成 `[]`(验收报告只能看不能改)②`blocks` 漏一行(卡面正文块恒空)③这次。
那几行旁边就写着两条「少写一行就静默落地」的警告 —— **措辞治不了的结构治**:
改成把 context 三样拆出去、**其余整包转交**,以后端口上新增可选字段不改这里也到得了;
剩下两处 `as never` 是类型收窄,不是丢值。断言钉在**最末端**:试跑观测面的卡带上 `redoable`,
e2e 断言「放行验收卡不摆重做(redoable=false 一路到了卡上)」—— 这类丢值只有末端断言拦得住。
- **人答得比这一拍收尾还快,这一拍就别报「等人」**(2026-09-01,同一夜第二种时序,又停掉一整例):
**决策卡的回传不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
`running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,再没有第三样东西会来叫
(工单回传那一路还有人事件兜底 + 昨夜补的 stale 那一下,决策卡这一路**一样都没有**)。
实测:卡 02:06:13.250 发出、**219ms 后**被答,这一拍 .636 才收尾,单子停到 20 分钟超时。
修:配方发卡收尾前再看一眼**刚发的这张卡还在不在等** —— 已经答了就照实说「还在跑」,
推进器下一轮接着处理那份答复;**只认这一张卡**,别的「等外部回音」照旧是等,不会空转成热循环。
同批把假端口改老实:**发出去的卡默认就在等**(从前发完卡还回「一张待答的都没有」,
那是生产里不会有的局面 —— 新那道门正是靠这个分辨的)。
- **打不回的验收卡就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该
有重做的选项,因为这里都没有接入 ai」):W05 的 T_release 整个由配方跑完(`guide` 是空串,
不问模型),件又是只读的 —— 打回之后**没有任何东西能接住那句意见**:配方只会原样再提交一次,
撞在「打回之后台账没动过就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的
兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆**(墙要配路;没有路就别立那扇门)。
契约加 `AcceptanceWorkOrder.redoable`(不写 = 打得回,老卡老行为);装配层按放行那一步的声明现算
(`releaseRedoable` 纯函数:有 `auto` 且 `guide` 是空串 → 打不回;半 auto 与模型驱动照旧打得回);
卡面收起理由框与那颗钮,换一句**真路**:「这一步只落账,退不回上一步 —— 有异议就别确认,
去终止这一单重开」;**服务端才是门**:`redoable === false` 的卡收到非 pass 的回传当场拒
(`redo-unavailable`),直调 API 也绕不过。两处变异验证;S3 结论审核那张审核卡不受影响 ——
它的重做现在真会带着意见重判(见上一条)。
- **答得太快就没人再叫醒它 —— stale 那条守卫补最后一下**(2026-09-01,一夜之内撞停两次,
第二次直接把我自己的验证卡住 15 分钟):卡发出 **270ms** 后人就答了,而**把 `lastProgressAt`
往前挪的那一拍,正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传,
2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)在这种时序下不成立;
回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。
三样凑齐:卡答完了、没有新卡在等、单子停着 —— **再没有第三样东西会叫醒它**
(手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。
修:`deliver` 判了 stale **照旧不跑这一拍**(它的本意不变),但把停着的单子拨回 `running`
交给推进器;真没事可干,它自己再停回去(代价一拍空转)。两条测钉死 + 变异验证。
真人点卡要几秒,撞不上这条窗口;**e2e 是 3 秒轮询 + 秒答,专把这种窗口撞出来** ——
它替人踩的正是这一类。
- **「我打回让改工作温度,它给我改了静态电流」—— 重判的行由确定的一侧圈,不由 AI 决定**
(2026-09-01 用户实测,`part-substitution-assessment@2.18.0`)。上一条刚把意见交回给 AI 重判,
当天就被这一单打脸,而库里的证据一字不差:**工作温度它没改**(还给了理由:候选值 1℃ 远窄于
-40~+85℃,硬约束不满足 —— 这半截是对的),而**没人问过的静态电流**被它从 soft_mismatch
翻成 match,`evidence_note` 里却写着「**维持原判**」。两张审核卡的「本轮结论」都还停在
S2 那一版(0 项一致 / 5 硬 / 2 软),而草稿此刻是 1 match / 5 硬 / 1 软 —— 差的那一项就是它。
**根因不是提示词不够严,是整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
而「我只改了那一条」这句话本身也出自它嘴里(它说了「维持原判」,可它改了)。规范 §4.6 纪律一
——**行由确定的那一侧决定,不由 AI 决定** —— 这次栽在自己手上。
治法(S3 复核轮从两笔变四笔,全部只在 `case_redo_note != ""` 那一轮跑):
①**圈范围**:按字面点名(`contains(case_redo_note, parameter_name)`,与基座给模型摆的
「意见点名对上了哪几行」同一种认法)把行标成 `named` / `kept`(明细表新增 `redo_scope` 列,tally);
②**兜底**:一条都没点到(「结论整体判得太松」这类意见说的是整批)→ 整表算被点名,
读的是上一笔刚写下的 tally 读数,不重新算一遍;③**重判只喂 `named` 的行** ——
没点名的行 AI 连见都见不到,自然改不着(也省 token);④**写回也只写这几行**,
模型漏答某一条时 `else` 兜回它上一轮的值(漏答不该变成「把它清空」)。
两处变异各自验证(写回不过滤 / 圈范围时一律标 named,都当场变红)。
e2e 案例 0 加一条**跨轮比对**断言:打回前存一份明细,放行前留最后一份,
逐行比「名字没出现在意见里的行,四态与三段叙述一个字都不许变」——
途中还逼出脚本自己的一个坑:**放行会清空草稿层**,收工时再取明细什么都没有,快照得留在放行之前。
- **重做的意见现在真交回给 AI 重跑那一步**(2026-09-01 用户:「唯一调用 ai 的这一步,使用重做后
是否没有将重做原因带回去重新让 ai 重跑?」——**确实没有**:S3 那一轮只把派生列重算一遍,
人写的那句话除了留痕之外无人读,AI 判过的四态一个字不会变,除非他自己在件上逐格改)。
平台加一样东西:引擎把「人这次打回写了什么」注给配方,名字 **`case_redo_note`** ——
下划线不是 `case:` 前缀,因为 S-FEEL 的名字不许带冒号(`lexer.ts` 的 NAME_PART),
而这句话最要紧的用法恰恰是当 `when` 条件用;**没打回也永远绑着空串** ——
未绑定的名字 `!= ""` 求值为**真**,不绑就等于「只在被打回时才跑」的那几笔每一轮都跑
(拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住)。静态审同批认这个名字
(它当场拦下过一次:「配方的 when 引用了 case_redo_note,而台账、案卷、起单入参里都没有这个名字」——
这道门做对了事)。
W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 —— **带意见重判**(入参比第一轮多两样:
上一轮的四态/影响/依据/UNKNOWN 去向,以及他写的那句话;判法与第一轮**同一份** `ASSESS_RULES`,
后面只接一段复核纪律:**没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉)
+ **判完落回明细**(与第一轮同一笔投影,只多一个 `when`)。为此把判法、出参形状、落明细那一笔
抽成三个共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。
**验收**:e2e w05 **5/5 released、被拒 0**,案例 0(先点名驳回、再通过)**模型调用恰 2 次**,
其余四例仍是 1 次 —— 没被打回就不多花一次模型。
- **「我重做后怎么直接推进了?」—— 打回与通过在收步那道门眼里一模一样**(2026-08-31 夜,
柴浩实测的那一单逐条对得上):审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」
→「**自动收步:S3_review —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。
意见落进了轨迹(还被 note-anchors 逐行对上了),而再没有任何东西会去读它。
**这条缺陷 2026-08-30 全分支终审时已被记录成 Important 2、还留着一条坐实病灶的测试**,
连「半个修法会把单子卡死」都预判对了 —— 这次照它的方向修完:
①收步那道门认答复(`answerSettlesStep`:`custom:`(人没选任何选项、写了自己的话)与约定的
`reject` 都不算办完;空答复照旧算「照原样确认」);②**同一句判断也让 `carded` 让位** ——
幂等挡的是「同一问被重复发出去」,而人点重做正是说「这一轮不算,改了再问我一遍」,
那不是重复,是下一轮:配方重跑(该重算的重算)再发一张,并在日志里说一句「人把这一步打回重做:…」。
只做①就是那个被预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
三条引擎测(重做→重开 / 重做但发不出卡→也不收 / 通过→照旧收)+ 3 条纯函数测,
两个半边各自变异验证;**e2e w05 5/5 released**,案例 0 现在真的发出**两张**审核卡
(先带意见驳回、再确认通过)。
**顺带撞见一条独立的老毛病并记账**(未修,见 debt.md):人答得比发卡那一拍收尾还快时,
回传被判 `stale`、`wake` 又被那一拍的 park 覆盖,单子停在 `awaiting-event` 再没人叫醒 ——
手工拨回 `running` 立刻走完。2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)
在这种时序下不成立:把时刻往前挪的那一拍,正是发出这张卡的那一拍。
- **「你怎么给我发成了执行工单」—— 面孔在真发卡那条路上被丢掉**(2026-08-31 用户实测,
`part-substitution-assessment@2.16.0`):装配层发执行工单那一处是一句**写死只认 `'data'`**
的三目(`engines.ts`),配方递下来的 `variant: 'accept'` 静默丢掉 —— 对话区照旧摆「执行工单」。
**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,前端那一头断言了
「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样全绿 —— 三处都对,而它们中间那一段把值扔了。
修:抽成纯函数 `executionCardVariant`(与旁边的 `decisionCardFace` 同一种做法、同一个测试位),
认 data / accept、拒 review、认不出的当没写;新测 4 条变异验证过。
e2e 同批**改认面孔不认标题**(试跑观测面的 `cards` 补 `variant`,契约变更):标题是活动自己写的
字符串,卡穿哪副面孔由装配层决定,两者可以对不上 —— 那一次正是「标题叫验收工单、发出去是执行工单」。
**面孔要在真发卡那条路上断言。**
同批(用户第二句「标题那一块需要是验收卡的那种绿色」):件① 验收工单那一副整套强调色换成
**验收绿 #0aa294**(令牌从 `ACCENT_LEDGER` 现改选择器得来,数值与件②③、对话区验收卡一处真源),
题头那条写死的浅蓝渐变单独给一条绿的;数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。
- **起单不许选到同一颗料;S1 数据齐了也叫人一次**(2026-08-31 用户两条,
`part-substitution-assessment@2.15.0`):
①**两格互斥**(「创建活动的启动参数里面不应该能选到同样的两个芯片」)—— 查下来不只是没意义,
是**把单子做死**:`sub_eval.part` 主键 `eval_ref + internal_pn`(role 不在主键里),配方铸的两行
撞成同一个主键、后一笔覆盖前一笔,两行收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立、
S1 收不了步,而件上那两行人加不出来(行源 `mine:`,没有「+加一行」)—— 唯一出路是终止重开
(与「两格留空」同一条链,清单头注早写着)。平台加互斥声明 `x-distinct`(契约 `distinctGroup`)——
与 `x-link` 正相反:那个是引导,**这个是墙**。前端把别的格占着的候选从这一格下拉里抽掉
(唯独自己填着的那个永远留着,否则 `<select>` 当场变空白),走「新增」自己写一个同样的 →
小红字 + 启动按不下去;服务端 `validateLaunchParams` 独立拦(脚本 / API 绕不过),400 点名后填那格。
「算不算同一个」= 去空白 + 不分大小写(`launchDistinctKey`,前后端同一把尺;只挡精确相等的话
`lm2596s-5.0` 照样进去,混一颗垃圾主数据出来)。
②**数据齐了也叫人一次**(「就算数据是足的也进入这个数据工单,就派发一个验收工单…把芯片卡片上的
编辑按钮去掉,只展示不能编辑,标题旁边的数据工单改成验收工单」)——病根在**引擎拍首那道
`doneWhen` 扫描**:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,配方一笔都还没跑,步骤当场被收掉,
卡永远发不出去。治法是 CMMN 给 S1 标 **`manualActivation`** —— 那道扫描多一个门槛(`worldTouched`),
次序变成「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 照旧是真门,只是不抢在人前面。
平台加**执行工单第三副面孔** `variant: 'accept'`(验收工单:件只读、人只确认,「确认无误」)——
与 `mode:'acceptance'`(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断抽成纯函数
`desk-task/internal/order-face.ts`(有测)。活动侧:S1 配方两笔互斥的 `card`(引擎 `carded` 幂等
保证一步只发一张 —— 数据工单发过之后验收工单永远轮不到,人不会被叫第二次),案卷加**粘着的旗**
`order_kind`(`data`/`accept`,第一档读上一拍写下的自己;不粘着的话人把最后一格填上、还没点确认,
件的题头就当场改名、笔也没了);**一张件两副面孔** —— 题头 / 标题 / 页脚三处名字与卡角编辑钮
按 `data-class-of="order_kind"` 投出的 `v-data`/`v-accept` 由样式表挑(藏的是容器 `.fabs`,
不是逐枚钮:那几枚各自的 display 由 `html.editable` 一串更特异的规则给着)。
**验收**:5 处变异验证;引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、
卡一张发不出);e2e w05 **5/5 released、被拒 0、模型调用恰 1 次** —— 都在库那例摆的正是
「验收工单 · 差异评估的输入核对」,缺东西那三例照旧是数据工单;真渲染核过 `.card` 两副 class。
2558 测 / 五门禁绿;2.15.0 装库六探针核过。
**副产**:e2e 样例 3「原PN缺库」的前提已被污染(`LM2596S-3.3` 现在真在库里,多半是白天 ops 起单
落的主数据),那一例实际走的也是验收工单那条路 —— 名字与实测不符,清库存那件事记在这儿。
- **「类型不一致还能点下一步」的病根:确认闸条件在发卡装配被丢 —— 手写字段清单第四次丢值**
(2026-09-01 用户:「两个芯片类型不一样,页面上方有红色提示,右边派发的卡片还可以点击下一步」):
活动白纸黑字声明了 `gateWhen`(2026-08-31 加的确认闸条件档),而**发卡装配那段手写清单**
(engines.ts 的 human 口 —— `variant` 上次被丢的同一段)逐项点名转交 `gateLedgers`/`variant`,
没点 `gateWhen`/`gateWhenSay` 的名:落库工单载荷里它是 undefined(稳定库任务 43 法证),
按钮禁用与服务端拒收读的都是它,**一起无声失效**;红带是视图画的所以照亮 ——
屏上两处说着相反的话。此前 fields / blocks / redoable 三次同款,这次的教训:
**中段是不可信卡骨架的缝,不能整包 spread,那就抽纯函数校验着转交并在 variant 的
测试位旁边钉死**(`cardConditionGate`,5 测:转交原样 / 不发明键 / 脏值不端 /
say 单独无意义 / when 可无 say)。**末端断言照 redoable 那课补上**:e2e w05 数据卡
阶段 `expectCardGateWhen` —— 卡一到手查 desk 载荷,丢了当场判失败。
活体验证(测试台真单):载荷带 `gateWhen` → detail 闸 `ready:false` 带活动那句话 →
API 强行确认 **400**;e2e w05 案例 0 全绿(released、被拒 0、模型 2 次)。
活动侧零改动(`GATE_OPEN_WHEN` 含类型不一致,mismatch 时发的必是带闸的数据卡;
⑧ 验收卡的件只读、碰不上这形)。门禁 exit 0(2595 测)。
- **「终止活动」接口被蒸馏吊 13–45 秒到报错:收尾不等模型**(2026-09-01 用户:「点击终止后
接口迟迟不返回,还会报错,但实例确实终止了」):法证在库里 —— 四单被终止的任务,
`finished_at` 与蒸馏条目 `created_at` 差 13.2 / 36.3 / 43.4 / 44.6 秒:`finish` 把终态
落库并广播**之后**,同步 `await` 了 `memory.distill`(整单记忆 + JSON schema 结构化输出的
一次真模型调用),HTTP 响应吊在半空 —— 屏上早显示已终止(SSE 那一刻就发了),响应拖到
任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。吊着的不止终止接口:
**放行收尾那一拍同样走 `finish`,推进器与任务租约陪模型等 40 秒**。修:蒸馏后台跑
(`void …distill().catch()`),封卷仍在前、失败照旧吞掉不掀终态;三测钉死(悬着的蒸馏不挡
control 返回 / 拒绝不外泄 / seal 在 distill 前),改前第一条即 30 秒超时 —— 变异天然验证。
CONTRACT.md 同步。门禁 exit 0(2590 测);:8787 已换上(watcher 只在重建时拉起、不守护
死进程 —— 杀了要 touch 源码触发重建才回来)。
- **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」,
`part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔
(jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写),
与发卡条件共用 GATE_OPEN_WHEN 一处真源;红条 data-class-of 投影 v-clear 收起。
**教训:要用「清成空串」表达状态翻转的列,空值不落笔会把它焊死 —— 开关就该是开关列。**
## 文档权威地图
| 层 | 文件 | 说明 |
@@ -1400,8 +2072,9 @@
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
| | `docs/ACT-WORK-05-端到端测试记录.md` | **ACT-WORK-05 差异评估的走查账**:六轮逐轮结论 + A–K 十一条发现总表(现象 / 病根 / 治法 / 层) |
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 的十张台账 / 110 列 / 人填列 27 —— 建活动时照它对字段 |
| | `docs/ACT-WORK-05-端到端测试记录.md` | **历史记录,已停止更新**(2026-08-30 标注):旧九步设计从零到 released 的走查账,六轮逐轮结论 + A–Z 二十六条发现总表(现象 / 病根 / 治法 / 层);四步重写(2026-08-29)之后的发现不再往里追加,散落在源码注释与 `docs/debt.md` |
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 重写后九张台账 / 66 列 / 人填列 20 —— 建活动时照它对字段 |
| | `docs/adr/ADR-014-deterministic-steps.md` | **确定性步骤(已实现,2026-08-29)**:W05 九步的确定/判断盘点、`PlanStep.auto` 声明式配方(五动词:`project`/`analyze`/`card`/`submit`/`say`)、引擎拍内次序、回溯验算、迁移次序、§11 待定前两条已定(决策卡给了 / 工具串接换成 `analyze`) |
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
File diff suppressed because it is too large Load Diff
@@ -1,67 +1,116 @@
/**
* 步骤剧本与呈现面 —— 这段错了不报错,只是活动开始转圈 / 界面开始撒谎(与 E04 / I01 同一套惯例)。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { ASSESSMENT_PHASES, ASSESSMENT_STEPS, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
import { manifest, D } from '../src/manifest.ts';
import { planStepSchema } from '@contracts';
import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map((m) => m[1] ?? '');
function doneOf(item: string): string {
const found = ASSESSMENT_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
/*
* **泛型 `Extract`,不是 `PlanStep` 标注**(2026-08-29,评审 fix round 1 改判)——
* `ASSESSMENT_STEPS` 不是元组,`.find()` 的返回类型是数组**元素类型**(四步 `auto.do` 里所有动作形状的并集),
* 与查的是哪个 `item` 无关。第一版把返回类型标成契约 `PlanStep`,而 `PlanStep['auto']` 落在
* `fields: Readonly<Record<string, AutoExpr>>` 这个**开放索引签名**上 —— 拼写保护是回来了,但整个
* 文件里按方括号取字段值的 14 处(含 S1 原有 `:155`/`:156`/`:166` 三条)全部失去编译期拼写检查,
* `evidence_note` 手滑打成 `evidence_noet` 之类的错字,`tsc` 会**静默放行**,只能等 `vitest` 断言
* 落空才发现,反馈反而往后挪了一步。
*
* 现在按查的 `item` 精确 narrow:四步各自的 `item` 字段已经在 `case-model.ts` 加了 `as const`,
* `(typeof ASSESSMENT_STEPS)[number]` 因此是四种带字面量 `item` 标签的形状的并集;`stepOf('S2_assess')`
* 传的是字符串字面量,泛型 `T` 原样推成 `'S2_assess'`,`Extract<并集, {item: T}>` 精确挑出**那一步自己的**
* 形状 —— 别的步的 `project`/`analyze` 形状不再混进来,`.fields['xxx']` 落回的是那一步真实写的字面量对象
* 类型(有名字的那几个属性),不是开放索引签名。拼错字段名会报 `TS2551` 并带拼写建议。
*/
function stepOf<T extends string>(item: T) {
return ASSESSMENT_STEPS.find(
(s): s is Extract<(typeof ASSESSMENT_STEPS)[number], { item: T }> => s.item === item,
);
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
/** 开局局面:入参 + 所有基座读数归零 */
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
demand_request_rows: 0,
demand_request_missing: 0,
[`${D}_verdict_rows`]: 0,
[`${D}_pn_check_rows`]: 0,
[`${D}_pn_check_missing`]: 0,
[`${D}_pn_check_exists_status_absent`]: 0,
[`${D}_pn_check_exists_status_abandoned`]: 0,
[`${D}_input_gap_rows`]: 0,
[`${D}_input_gap_missing`]: 0,
input_confirmed_total: 0,
review_approved_total: 0,
landing_prepared_total: 0,
substitution_relation_rows: 0,
substitution_relation_missing: 0,
substitution_relation_analysis_status_fae_confirmed: 0,
substitution_difference_item_rows: 0,
substitution_difference_item_missing: 0,
substitution_difference_item_distinct_substitution_relation_id: 0,
substitution_difference_item_difference_status_match: 0,
substitution_difference_item_difference_status_mismatch: 0,
substitution_difference_item_difference_status_unknown: 0,
substitution_difference_item_difference_status_conditional: 0,
tool_part_substitution_assessment_compare_parameters_calls: 0,
tool_part_substitution_assessment_recommend_calls: 0,
});
describe('剧本与案例模型对得上', () => {
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
for (const step of ASSESSMENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
for (const id of ITEM_IDS) expect(ASSESSMENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
describe('四步案例模型', () => {
it('恰好四步,次序即业务次序', () => {
expect(ASSESSMENT_STEPS.map((s) => s.item)).toEqual(ITEMS);
});
it('清单里挂的就是这份剧本与这份阶段', () => {
expect(manifest.assets.businessModel.steps).toBe(ASSESSMENT_STEPS);
expect(manifest.assets.businessModel.phases).toBe(ASSESSMENT_PHASES);
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);
}
});
});
/*
* 恢复自 Task 2 遗留的两条 `it.skip`(原住在这份文件里,注释指名「Task 2 → Task 3 补回」)——
* 旧版断言的是旧九步剧本引旧台账地址 / 旧两枚工具,那两个前提随四步重写一起撤了,
* 这里按新的四步剧本与九张新台账重新写实(逻辑不变:读写面必须落在清单声明里、
* 工具必须落在声明过的工具里),不是新造的断言。
*/
describe('剧本声明的读写面 ⊆ 清单声明', () => {
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
@@ -69,253 +118,506 @@ describe('剧本声明的读写面 ⊆ 清单声明', () => {
it('steps[].writes / reads(台账那几个)全落在声明过的对象上', () => {
for (const step of ASSESSMENT_STEPS) {
for (const address of step.writes) expect(declaredAddresses.has(address), `${step.item} 要写 ${address}`).toBe(true);
/* reads 里混着入参名(originalInternalPn 等)与 case: 前缀伪读数,只有「域.对象」形状的才对声明表 */
for (const address of step.reads.filter((r) => r.includes('.') && !r.startsWith('case:'))) {
expect(declaredAddresses.has(address), `${step.item} 要读 ${address}`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具;只有 S3 调工具', () => {
it('steps[].tools 全部是声明过的工具 —— 且四步全是空数组(零工具是这次重写的立场,不是疏漏)', () => {
for (const step of ASSESSMENT_STEPS) {
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
if (step.item !== 'S3_assess') expect(step.tools, step.item).toEqual([]);
expect(step.tools, step.item).toEqual([]);
}
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.tools).toHaveLength(2);
});
it('customer_approval_status 在任何剧本里都不许写(只读边界)', () => {
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(/customer_approval_status\s*=/);
});
});
describe('doneWhen —— 开局不许真,干完必须真', () => {
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of ASSESSMENT_STEPS) {
if (step.doneWhen === undefined) continue;
expect(safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>)), `样例「${sample.name}」下 ${step.item} 开局就为真`).toBe(false);
}
describe('S1 配方', () => {
const recipe = stepOf('S1_inputs')?.auto;
const acts = recipe?.do ?? [];
const kinds = acts.map((a) => a.kind);
/*
* 次序是承重的,不是随手写的(2026-08-29 评审 Important 修正):案卷投影(`sub_eval.case`)
* 的 `gate_reason` 要读 `parts_profile_missing`/`parts_parameter_missing` —— 这两个数由
* 主数据/参数骨架两笔撑出来,案卷这一笔必须排在它们**之后**,否则读到的是这一拍刷新之前
* 的旧数。病根写在 case-model.ts ④ 那一笔头上;引擎级的复现(旧次序真的会让 gate_reason
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
*/
it('七笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 数据工单 → 验收工单 → 交代', () => {
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'card', 'say']);
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
]);
});
/*
* **数据齐了也叫人一次**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个
* 验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)。两张卡是互斥的两条路,不是先后:
* 引擎的 `carded` 幂等保证一步只发一张 —— 数据工单发过之后,验收工单这一笔永远轮不到
* (补完数据不会再被叫第二次)。
*/
it('两张卡:缺东西派数据工单(读闸)· 都齐了派只读验收工单(读上一笔刚写下的粘旗)', () => {
const cards = acts.filter((a) => a.kind === 'card');
expect(cards.map((c) => (c.kind === 'card' ? c.variant : undefined))).toEqual(['data', 'accept']);
/* 验收工单不重算一遍闸 —— 读的是案卷刚写下的旗,同一件事只许有一处判定 */
expect(cards[1]?.kind === 'card' && cards[1].when).toBe('sub_eval_case_order_kind_accept > 0');
/* 两张都是执行工单(验收工单是它的变体,不是第三种 mode);零输入,填报/核对都在件上 */
for (const c of cards) expect(c.kind === 'card' && c.as).toBe('execution');
/* 闸同一份:件只读也要在确认那一刻再数一遍「数据是齐的」 */
for (const c of cards) expect(c.kind === 'card' && c.gateLedgers).toEqual(['parts.profile', 'parts.parameter']);
});
/*
* 旗**粘着**:第一档读的是上一拍写下的自己(tally 读数)。不粘着的后果实打实 ——
* 人在数据工单上把最后一格填上、还没点确认,闸当场变 clear,件的题头会当场改名叫
* 「验收工单」、卡角的笔也没了,而他手里那张卡还开着。
*/
it('order_kind 粘着:已经是 data 就一直是 data,否则按此刻的闸决定(与发卡条件同一句)', () => {
const caseAct = acts.find((a) => a.kind === 'project' && a.into === 'sub_eval.case');
const kind = caseAct?.kind === 'project' ? caseAct.fields['order_kind'] : undefined;
const gate = acts[4]?.kind === 'card' ? acts[4].when : undefined;
expect(kind).toEqual({
when: 'sub_eval_case_order_kind_data > 0',
then: 'data',
/* 第二档与数据工单那张卡读的是同一句闸(一处真源) */
else: { when: gate, then: 'data', else: 'accept' },
});
});
/*
* 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」
* 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行,
* 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。
*
* **带 `prune`,已落地的行由基座 `skipLanded` 跳过**(2026-08-31 改回)。曾特意不带
* `prune`(2026-08-30 e2e 案例 2:已落地的旧类型行撤不掉,配方整单炸)—— 代价是
* **没落地的**陈旧行也不清:人把候选从 LDO 改回 DC-DC,LDO 的字典/字段行积在屏上
* (2026-08-31 用户实测点名「下面还是显示两个类型」)。现在 `draftDrop` 对配方这一路
* 跳过已落地的行(loop-engine/port.ts),prune 回来:没落地的陈旧选型照撤,
* 已落地的原地留下 —— 案例 2 不再炸,用户那条路清得干净。
*/
/*
* SKIP(2026-08-31 用户定「只选不建」):②③ 两笔自投影连同 gateLedgers 上那两张表
* 一并退役 —— 类型只许从字典里选,选中的行本来就在正式表里,镜像与落地都失去了对象。
* 这条钉的机制(prune + skipLanded)仍活在 auto-runner 测试里,不随本活动退役。
*/
it.skip('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(带 prune,已落地由基座跳过)', () => {
expect(true).toBe(true);
});
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 : undefined;
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' });
});
/*
* **2026-08-30 修 Critical 2**:两个启动参数改成必填之后(`manifest.ts` 的
* `contract.input.required`),`sub_eval_part_rows < 2` 只剩一条能触发它的路径 ——
* 两颗料号填成了同一个(`internal_pn` 撞成同一把主键,两行覆盖成一行)。旧文案
* 「原料号与候选料号还没填全 —— 在下面把两格补上」指向一条不存在的路(块① 没有
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
*/
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
const kase = acts[3];
const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
expect(gateReason?.then).not.toContain('还没填全');
expect(gateReason?.then).not.toContain('把两格补上');
expect(gateReason?.then).toContain('终止');
});
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[2];
/* 'live':人加不了行的表,quiet 拍也撤 —— 改类型后旧参数行跟手消失(2026-08-31 用户点名「只会变多不会变少」) */
expect(params?.kind === 'project' && params.prune).toBe('live');
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 : undefined;
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');
});
/*
* 2026-08-30,Ruling H35(推翻 2026-08-29 评审 Critical 那条旧结论,不是又改主意):
* 从前 `parts.chip_type` 永久不列进这张卡的闸,理由是「它没有编辑手段,`mine:` 天然
* 为空,列了就永久卡死」——那条理由**在 ②③ 两笔自投影加上之后不再成立**。
* 而不列进闸的代价是真实存在的:「数据工单确认即入库」按 `gateLedgers` 整批并入正式表,
* `parts.chip_type` 不在名单里,人新建的类型字典行就只留在草稿层、从不落地,
* 引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 —— 一条悬空引用
* (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。
*/
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 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.profile', 'parts.parameter']);
/*
* 类型不一致不许确认(2026-08-31 用户定)。钉 parts.profile 本表的 distinct ——
* 提交闸从草稿现读零滞后;钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦。
*/
expect(card?.kind === 'card' && card.gateWhen).toBe('parts_profile_distinct_chip_type_id <= 1');
expect(card?.kind === 'card' && typeof card.gateWhenSay === 'string' && card.gateWhenSay.includes('不是同一个芯片类型')).toBe(true);
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
});
it('红条开关 gate_state 与发卡条件同一句判定(一处真源)—— gate_reason 清空不落笔,开关列两个方向都真写', () => {
const kase = acts[3];
const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gs = fields?.['gate_state'] as { when?: string; then?: string; else?: string } | undefined;
const card = acts[4];
expect(gs?.then).toBe('open');
expect(gs?.else).toBe('clear');
expect(card?.kind === 'card' && card.when).toBe(gs?.when);
});
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);
}
});
});
it('S0:两颗候选 + 原 PN = 3 行核对齐、需求与结论都写了 → 收得掉;少写一颗 → 收不掉', () => {
const base = { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'], demand_request_rows: 1, demand_request_missing: 0, [`${D}_verdict_rows`]: 1, [`${D}_pn_check_missing`]: 0 };
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 3 })).toBe(true);
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 2 })).toBe(false);
describe('S2 配方 —— AI 只出场一次', () => {
const recipe = stepOf('S2_assess')?.auto;
const acts = recipe?.do ?? [];
it('六笔:分析 → 关系(先摆一遍满足 refersTo)→ 明细 → 关系(重算)→ 验证记录 → 交代;analyze 恰好一笔', () => {
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'say']);
expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
/* 两次 project 到 substitution.relation 是同一个引用(RELATION_PROJECTION),不是抄了两份 */
expect(acts[1]).toBe(acts[3]);
expect(acts[1]?.kind === 'project' && acts[1].into).toBe('substitution.relation');
expect(acts[2]?.kind === 'project' && acts[2].into).toBe('substitution.difference_item');
});
it('S2:未放弃的候选各一条关系且每条都有明细 → 收得掉;放弃一颗后关系数随之减一', () => {
const two = { candidateInternalPns: ['A', 'B'], [`${D}_pn_check_exists_status_abandoned`]: 0, substitution_difference_item_rows: 14 };
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 2 })).toBe(true);
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(false);
expect(safeEval(doneOf('S2_normalize'), { ...two, [`${D}_pn_check_exists_status_abandoned`]: 1, substitution_relation_rows: 1, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(true);
it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
const items = acts[2];
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
/*
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
* `SomeShape | {}` 在按方括号取值时会被 TS 整体收窄成裸 `{}`(`{}` 是任何对象的父类型,
* 联合化简会把更具体的那一支吃掉),于是**全部**字段访问失去编译期保护、报
* 「不能索引类型 {}」,与 `stepOf` 标没标 `Extract` 无关(受控实验见任务报告)。
* `: undefined` 不会被这样吃掉,后续按 `?.[key]` 取值,与 S1 既有的
* `:164`/`:172`/`:175` 三处同一写法 —— 断言真跑到的值不变(条件为真时 `?.` 无副作用)。
*/
const fields = items?.kind === 'project' ? items.fields : undefined;
/* 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('S3:四态之和 = 明细行数、必填齐、两枚工具都调过 → 收得掉;工具没调过 → 收不掉', () => {
const ok = {
substitution_difference_item_missing: 0,
substitution_relation_missing: 0,
substitution_difference_item_rows: 7,
substitution_difference_item_difference_status_match: 4,
substitution_difference_item_difference_status_mismatch: 1,
substitution_difference_item_difference_status_unknown: 1,
substitution_difference_item_difference_status_conditional: 1,
tool_part_substitution_assessment_compare_parameters_calls: 1,
tool_part_substitution_assessment_recommend_calls: 1,
it('**值不取 AI 出参,一律 lookup 台账**', () => {
const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
for (const col of ['original_value', 'candidate_value'] as const) {
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
}
/* 约束类型来自字典这一行本身,不是 AI 说的 */
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
});
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
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 出参、不读自报列', () => {
// acts[1] 与 acts[3] 是同一个 RELATION_PROJECTION 引用,查哪个都一样
const rel = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
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[4];
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 : undefined;
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 : undefined;
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 : undefined;
expect(fields?.['param_desc']).toEqual({ col: 'param_desc' });
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
for (const col of ['original_value', 'candidate_value'] as const) {
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
}
});
});
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', 'analyze', 'project', 'project', 'project', 'project', 'say', 'say', 'card']);
for (const at of [0, 2, 3]) expect(s3[at]?.kind === 'project' && s3[at].into).toBe('substitution.difference_item');
expect(s3[4]?.kind === 'project' && s3[4].into).toBe('substitution.relation');
expect(s3[5]?.kind === 'project' && s3[5].into).toBe('validation.record');
});
/*
* **人打回时,意见要交回给 AI 重判**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
* 重新让 ai 重跑这一步?」)—— 在此之前 S3 那一轮只把派生列重算一遍,人写的那句话除了留痕
* 之外**无人读**:AI 判过的四态一个字不会变,除非人自己在件上逐格改。
*
* 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的那句话(没打回就是空串)——
* 常路(第一次送审)这两笔跳过,整个活动仍然只调一次模型。
*/
it('复核轮四笔都钉在「人打回了才跑」上 —— 常路一笔都不跑,整单仍只调一次模型', () => {
/* `when` 不是每一种笔都有的键 —— 按「有没有这个键」取,不做属性访问(与本文件既有的 `'prune' in …` 同一写法) */
const whenOf = (at: number): unknown => {
const act = s3[at];
return act !== undefined && 'when' in act ? act.when : undefined;
};
expect(safeEval(doneOf('S3_assess'), ok)).toBe(true);
expect(safeEval(doneOf('S3_assess'), { ...ok, tool_part_substitution_assessment_recommend_calls: 0 })).toBe(false);
expect(safeEval(doneOf('S3_assess'), { ...ok, substitution_difference_item_difference_status_match: 3 })).toBe(false);
});
it('S5:旗 + 全部关系 FAE 确认 + P-01 回写齐 → 收得掉;缺一头都收不掉', () => {
const ok = { landing_prepared_total: 1, substitution_relation_analysis_status_fae_confirmed: 2, substitution_relation_rows: 2, substitution_relation_missing: 0, demand_request_missing: 0 };
expect(safeEval(doneOf('S5_prepare_landing'), ok)).toBe(true);
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, substitution_relation_analysis_status_fae_confirmed: 1 })).toBe(false);
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, demand_request_missing: 1 })).toBe(false);
});
});
describe('说明书与剧本的骨架', () => {
it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => {
expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"');
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S0d_p08_master"');
expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)');
expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)');
expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认');
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S4_review"');
});
it('剧本里不引反例原话;S3 固定三拍且写明「工具后同拍作废」', () => {
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
expect(s3).toContain('第一拍');
expect(s3).toContain('第二拍');
expect(s3).toContain('第三拍');
expect(s3).toContain('工具调用后同拍其余动作作废');
});
it('S3 第三拍写盘前先自检两枚工具的调用次数(2026-08-27 实测:漏调 recommend 会让 doneWhen 恒假、模型死循环重写同样的行)', () => {
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
const thirdPass = s3.slice(s3.indexOf('第三拍'));
expect(thirdPass).toContain('tool_part_substitution_assessment_recommend_calls');
expect(thirdPass).toContain('tool_part_substitution_assessment_compare_parameters_calls');
expect(thirdPass).toMatch(/自检|先.*找这两个数|必须\s*>\s*0/);
// 第二拍那一段要点出「推荐等级只能由工具算,不调它收不了步」,别让模型自己拍推荐等级
const secondPass = s3.slice(s3.indexOf('第二拍'), s3.indexOf('第三拍'));
expect(secondPass).toContain('推荐等级只能由它算');
expect(secondPass).toMatch(/收不了步|收不掉/);
});
it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本按候选总数判断该不该问人(=1 必建、≥2 才问、原 PN 硬阻塞)', () => {
const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
expect(s0).toContain('当下可为');
expect(s0).toContain('不要对计划项用');
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('硬阻塞');
expect(s0d).toContain('候选总数 = 1');
});
it('S0d_p08_master 剧本按候选总数(不是「不在库的颗数」)判断要不要问人 —— 候选总数 ≥2 时逐颗不在库的都要问(2026-08-27 实测逼出的措辞修正)', () => {
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('候选总数');
expect(s0d).toContain('不在库');
expect(s0d).toMatch(/候选总数\s*≥\s*2/);
// 反面提示:「不在库的只有一颗」不该被读成跳过决策卡的理由
expect(s0d).toContain('不是跳过决策卡的理由');
});
it('s_bound 哨兵真的检查了「有料号不在库」这件事(都在库时门不该开;退回旧写法会红)', () => {
expect(CMMN_XML).toContain('sub_eval_pn_check_exists_status_absent &gt; 0');
const sBoundBlock = /<sentry id="s_bound">[\s\S]*?<\/sentry>/.exec(CMMN_XML)?.[0] ?? '';
expect(sBoundBlock).toContain('caseFileItemRef="sub_eval_pn_check_exists_status_absent"');
expect(sBoundBlock).toContain('sub_eval_pn_check_exists_status_absent &gt; 0');
});
it('说明书与全部剧本里不会教模型对计划项调用 disable(允许出现在「不要 disable」这类禁止句里)', () => {
// 只逮「disable + 计划项 id」这种祈使形(教模型真的去调用它);
// 「不要…用 `disable`」「Disabled 只能从 Enabled 来」这类禁止句/状态名不含「disable + id」,不会被误伤。
const disableCallPattern = /disable\s+(S\d[a-zA-Z_]*|T_release)/;
expect(NARRATIVE).not.toMatch(disableCallPattern);
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(disableCallPattern);
});
it('S0d 拆成两项各自绑自己的 view(P-10 那份件不会永远推不出来),两项都没有 doneWhen', () => {
const p08 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master');
const p10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document');
expect(p08?.view).toBe('p08-master-create');
expect(p10?.view).toBe('p10-document-create');
expect(p08?.doneWhen).toBeUndefined();
expect(p10?.doneWhen).toBeUndefined();
});
it('候选放弃到只剩最后一颗时不许再放弃(至少留一颗,防止全放弃死锁)', () => {
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('至少留一颗候选');
});
it('P-08 交回之后先 finish 再发 P-10 卡,顺序写死进两段 guide(2026-08-27 实测:漏收步会让工作区画面停在 P-08 那两张表,P-10 卡发出后人却看不见资料表、提交闸拦死)', () => {
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(s0d).toContain('finish S0d_p08_master');
expect(s0d).toMatch(/顺序不能反/);
expect(s0d).toContain('p08-master-create');
const s0d10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document')?.guide ?? '';
expect(s0d10).toContain('finish S0d_p08_master');
expect(s0d10).toMatch(/若它还开着|先.*finish/);
});
it('「发卡前清单必须先有行」写在说明书里,不只在剧本里(2026-08-27 实测:模型把 P-10 收步与 S1 发卡压成一拍,那一拍 S1 剧本没摊开,卡发出去时 input_gap 一行都没有,人对着空表无从下手)', () => {
expect(NARRATIVE).toContain('sub_eval.input_gap` 必须先有行');
expect(NARRATIVE).toMatch(/压拍/);
expect(NARRATIVE).toMatch(/顺序不能反/);
});
it('存在性核对的剧本教的是「填得出依据才算在库」,不是「你看着办」(结构治,配 found_pn_ref 那道墙)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
expect(g).toContain('found_pn_ref');
expect(g).toMatch(/查无此值当场拒/);
expect(g).toMatch(/名单里没有就是没有/);
expect(g).toMatch(/把这一行改成 absent/);
});
it('数据工单的名单只认 absent,「建哪几颗」与「要不要问人」分开写(2026-08-27 实测:原 PN 缺库那单,模型把在库的候选派成了数据工单,缺的那颗没人建)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(g).toMatch(/问题一 · 建哪几颗/);
expect(g).toMatch(/问题二 · 建之前要不要先问人/);
expect(g).toMatch(/exists_status = "found" 的一行都不建/);
});
it('数据录入卡自带 basis,不让基座兜上内部家具(2026-08-27 实测:没声明 basis 的那张卡,参考信息栏摆的是「这一步 / 当下可为 / 目标缺口」加九个计划项全名)', () => {
const i = NARRATIVE.indexOf('"kind":"entry"');
expect(i).toBeGreaterThan(-1);
const block = NARRATIVE.slice(i, i + 900);
expect(block).toContain('"basis"');
expect(block).toContain('触发原因');
});
it('三张执行工单各自声明 gateLedgers —— 零输入卡不声明,闸就盯着与它无关的表(2026-08-27 e2e 实测:两张数据工单空手交卷都放行了)', () => {
for (const [item, ledger] of [
['芯片主数据创建', '"gateLedgers":["parts.profile","parts.parameter"]'],
['原厂技术资料创建', '"gateLedgers":["parts.document"]'],
['评估输入与资料补齐确认', '"gateLedgers":["sub_eval.input_gap"]'],
] as const) {
expect(NARRATIVE, item).toContain(ledger);
for (const at of [0, 1, 2]) expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('case_redo_note != ""');
/* 后面那几笔(重算 / 关系 / 验证)照旧每一轮都跑 —— 人就地改了四态也要跟着重算 */
for (const at of [3, 4, 5]) expect(whenOf(at), `第 ${at} 笔`).toBeUndefined();
/*
* **点不到名就一条都不改**(2026-09-01 当天上线当天撤的那一笔:整表重判)——
* 重判与写回都多一个前提「真点到了名」,免得一句与参数无关的话(实测:「收口结论里面的
* 推荐理由改为非常不推荐」)把 5 条参数的结论一起改掉,还白烧一次模型。
*/
for (const at of [1, 2]) {
expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('substitution_difference_item_redo_scope_named > 0');
}
});
it('数据工单也要「先写骨架再发卡」写进说明书,且哨兵不认零行主数据(2026-08-27 e2e:模型压拍没写骨架就发卡,人打开是空表,零行同时骗过闸与哨兵,一路走到 S2 才被引用检查拦下)', () => {
// 两张数据工单各要一条(P-08 的 profile/parameter、P-10 的 document)
expect(NARRATIVE.match(/必须先有骨架行/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
expect(NARRATIVE).toContain('parts.document` 必须先有骨架行');
expect(NARRATIVE).toMatch(/压拍/);
// 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了)
expect(CMMN_XML).toContain('parts_profile_rows &gt;= sub_eval_pn_check_exists_status_created');
/*
* **谁可以变,由确定的一侧圈**(2026-09-01 用户实测:「我打回让修改工作温度的结论,给我改了
* 静态电流的结论」)—— 第一版把整表交给 AI 重判、整表写回,没有任何东西限制它只动被点名的
* 那一行;它改了没人问过的那条,`evidence_note` 里还写着「维持原判」。
* 现在:①按字面点名圈出 `named`;③入参**只有** `named` 的行;④写回也只写这几行。
*/
it('圈范围:点名的标 named,其余 kept —— 一条都没点到时什么都不改,只把话说清楚', () => {
const mark = s3[0];
const markFields: Readonly<Record<string, unknown>> = mark?.kind === 'project' ? mark.fields : {};
expect(markFields['redo_scope']).toEqual({ when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' });
/* 没点到名的那一轮:一句交代,告诉他怎么才办得成 —— 而不是猜「他指的是全部」 */
const noHit = s3[7];
expect(noHit?.kind === 'say' && noHit.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named = 0');
const text = noHit?.kind === 'say' ? String(noHit.text) : '';
expect(text).toContain('一条都没改');
expect(text).toContain('参数名');
/* 收口四格不归 AI 判 —— 那几格人在件上直接改(2026-08-31 立的口径) */
expect(text).toContain('件上直接改');
});
it('评估输入那张卡的**前提**写在说明书的卡骨架上(2026-08-27 e2e:两颗缺库,模型只问只建了第一颗,CN 还 absent 就把 S1 的卡发了出去 —— 发卡的授权面是调人预算,绕得过哨兵)', () => {
expect(NARRATIVE).toContain('一颗 absent 都不剩');
expect(NARRATIVE).toMatch(/有几颗 absent 就要办几颗/);
it('重判与写回都只认 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着', () => {
const redo = s3[1];
const land = s3[2];
const named = { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } };
expect(redo?.kind === 'analyze' ? redo.input.from : undefined).toEqual(named);
expect(land?.kind === 'project' ? land.from : undefined).toEqual(named);
/* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */
const rules = s2[0]?.kind === 'analyze' ? s2[0].instruction : '';
expect(redo?.kind === 'analyze' && redo.instruction.startsWith(rules)).toBe(true);
/*
* **复核轮审核人说了算**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」)——
* 他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 搬出第一轮那条
* 「硬约束不因措辞变成 match」把他顶了回去。那条规矩管的是**它自己判**的时候别被措辞带偏;
* 审核人是这一步的责任人(要签他的字,他在件上本来就能亲手改任何一条四态),不归它管。
* 指令里必须**明写这条覆盖**,否则前面那份规矩排在前面、模型照旧照它办。
*/
const tail = redo?.kind === 'analyze' ? redo.instruction.slice(rules.length) : '';
expect(tail).toContain('这一轮不归它管');
expect(tail).toContain('他点名要什么结论,就落什么');
expect(tail).toContain('不要顶回去');
/* 落的同时要把「按谁的意见、数据面是什么」记进依据 —— 下一个人得能分清人定的与数据判的 */
expect(tail).toContain('数据面');
/* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */
const fields: Readonly<Record<string, unknown>> = redo?.kind === 'analyze' ? redo.input.fields : {};
for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) {
expect(Object.keys(fields)).toContain(key);
}
expect(fields['reviewer_note']).toEqual({ in: 'case_redo_note' });
/* 模型漏答某一条:兜回这一行上一轮的值 —— 漏答不该变成「把它清空」 */
const landFields: Readonly<Record<string, unknown>> = land?.kind === 'project' ? land.fields : {};
for (const col of ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition']) {
expect(landFields[col]).toMatchObject({ lookup: 'result:items', else: { col } });
}
/* 派生列与两处创建笔共用同一份 when 链 */
const s2Fields: Readonly<Record<string, unknown>> = s2[2]?.kind === 'project' ? s2[2].fields : {};
expect(landFields['judgement_class']).toBe(s2Fields['judgement_class']);
});
it('发数据录入卡那一拍不许顺手写需求行(客户还没落草稿层,引用检查必拒)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
expect(g).toMatch(/那一拍只发卡/);
expect(g).toContain('引用检查');
/*
* **这是 2026-08-30 task-12「结论纹丝不动」缺陷的回归钉**:S3 从前直接从
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 起步,人改的 `difference_status`
* 没有任何一笔把它转译成 `judgement_class`,两笔投影读到的 tally 纹丝不动。
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
*/
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
const recompute = s3[3];
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
expect('prune' in (recompute ?? {})).toBe(false);
const fields = recompute?.kind === 'project' ? recompute.fields : undefined;
/* 只有两把主键(定位这一行)+ 两个派生列(judgement_class/risk_level)—— 一个都不多写:
difference_status / impact_statement / evidence_note / unknown_disposition(人可以就地改的那几列)
一个都不在这份 fields 里,靠 draftWrite 的稀疏 patch 语义保证不被这一笔碰到。 */
expect(Object.keys(fields ?? {}).sort()).toEqual(['difference_item_id', 'judgement_class', 'risk_level', 'substitution_relation_id']);
});
it('absent 没清零之前不许收掉数据工单那一项(2026-08-27 e2e:建完第一颗就 finish,再想给第二颗发卡撞上「这一步已经办完收掉了」,连拒四次)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(g).toMatch(/还有没有 absent 的料号/);
expect(g).toMatch(/这一项千万别 finish/);
expect(g).toMatch(/收掉了就回不去了/);
/*
* **判定分类 / 风险等级的 when 链只许有一处真源**:S3 的重算笔与 S2 差异明细创建笔的
* `judgement_class`/`risk_level` 必须是**同一个对象引用**(都来自 `...JUDGEMENT_DERIVED_FIELDS`
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
*/
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
const s3Fields = s3[3]?.kind === 'project' ? s3[3].fields : undefined;
const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined;
expect(s2Fields?.['judgement_class']).toBeDefined();
expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']);
expect(s3Fields?.['risk_level']).toBe(s2Fields?.['risk_level']);
});
it('参数名一律中文、与库里既有行同名(2026-08-27 e2e:模型写成 input_voltage_range,而原 PN 是「输入电压范围」—— 主键的一半对不上,27 条里 14 条判成 UNKNOWN)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(g).toMatch(/参数名一律用中文/);
// 措辞写过两遍都没拦住 → 上结构:把「同一套名字」变成基座算得出的数
expect(CMMN_XML).toContain('parts_parameter_rows = parts_parameter_distinct_param_name * sub_eval_pn_check_exists_status_created');
expect(g).toContain('input_voltage_range');
expect(g).toMatch(/不是同一行/);
/**
* **S3 的关系笔只重算判定,收口四格一个字不碰**(2026-08-31 用户:「差异评估结论审核这个
* 界面的收口结论下面这 4 个字段也需要可以编辑」)。
*
* 此前 S2 与 S3 共用同一份 `RELATION_PROJECTION`(「各写一份早晚分叉」)。件上把四格打开
* 之后那条口径不能再照搬:那一笔每跑一次就按规则重写这四列,而人在件上按保存会触发
* quiet 拍、quiet 恰恰跑 `project` —— 人刚写下的话当场被冲掉,等于格子白开。
*
* 现在 S3 换成 `RELATION_RECOMPUTE`:只列主键 + `recommendation_level`。`patch` 是稀疏的、
* DB 侧是 jsonb 浅合并,没列的列原样留着 —— **这条断言的要害就是「没列」**,列了就等于没改。
* 而 `recommendation_level` 必须仍在(判据核心:件② 判据表 / 件③ 落账结论 / 出口谓词都读它,
* 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。
*/
it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => {
const relation = s3[4];
expect(relation?.kind).toBe('project');
const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly<Record<string, unknown>> =>
act?.kind === 'project' ? act.fields : {};
const fields = fieldsOf(relation);
for (const col of ['recommendation_reason', 'difference_summary', 'risk_level', 'verification_requirement']) {
expect(Object.keys(fields), col).not.toContain(col);
}
/* 判定仍要重算,且与 S2 那份共用同一条 when 链 —— 两处各写一份早晚分叉 */
const s2Relation = fieldsOf(s2[3]);
expect(fields['recommendation_level']).toBeDefined();
expect(fields['recommendation_level']).toBe(s2Relation['recommendation_level']);
/* 主键带回:同 PK 覆盖,不是新开一行 */
expect(fields['substitution_relation_id']).toBeDefined();
/* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */
expect(s3[5]).toBe(s2[4]);
});
it('第二颗缺库的候选也要先问(每一颗都有被放弃的权利)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(g).toMatch(/这一颗也要先发一张决策卡问人/);
expect(g).toMatch(/第一颗问过了不代表第二颗也问过/);
/*
* **这一条读的是真 `case-model.ts`,不是任何镜像**(2026-08-29 复审 Important fix round 2)——
* `w05-s3-review-recompute.test.ts` 证明的是机制(S3 少了 `let`,复用的投影主键就算不出来,
* 写会被静默丢掉、或拼出一行垃圾);但那份测试手写镜像、不 import 这份真文件(架构上也不能),
* 红绿与 `case-model.ts` 此刻写的是什么**完全脱钩**——明天有人把 `S3_review.auto.let` 删掉,
* 原样重犯这次的 bug,`AutoRecipe.let` 是可选字段(typecheck 不报),镜像测试也不会变红。
* 这一条补上「读真文件」那一半:S3 与 S2 的 `auto.let` 必须是**同一个引用**,少了就在这里当场炸。
*/
it('S3 与 S2 共用同一个 `let` 引用 —— 少了它,复用的投影主键在 S3 那一拍就是未绑定的空串', () => {
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let);
});
it('created 认的是「卡交回来了」,不是「表里有行」—— 写骨架 ≠ 建好了(2026-08-27 e2e:模型铺完骨架就标 created、直接 finish,撞「从没给它发过卡」连拒五拍)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(g).toMatch(/写骨架 ≠ 建好了/);
expect(g).toMatch(/写骨架 → \*\*发卡\*\* → 这一拍结束 → 人交回/);
expect(g).not.toMatch(/只有它在 \\`parts\.profile\\` 里真有一行/);
/*
* **交代必须排在卡之前**(2026-09-01 用户:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,
* 不然都不知道发生了什么」)—— 发了卡这一拍就收了,排在卡后面的 `say` 要等**下一拍**才跑,
* 那时人早看完卡了。实测:原来那句「差异结论已送审,请核对」出现在人答完之后。
* 两句互斥:被打回那一轮说「按你的意见改了哪一条、改成什么」(摆的是件上那一行自己的
* `evidence_note`,不另编一句);第一轮说「结论已备好,请核对」。
*/
it('打回那一轮的交代排在卡之前,摆的是件上那一行的判断依据', () => {
const kinds = s3.map((a) => a.kind);
/* **排在卡之前**:发了卡这一拍就收了,排在后面的 say 要等下一拍 —— 那时人早看完卡了 */
expect(kinds.lastIndexOf('say')).toBeLessThan(kinds.indexOf('card'));
/* 只在被打回那一轮说 —— 第一轮那句与卡自己的 ask 是同一件事,退役了 */
const redoSay = s3[6];
expect(redoSay?.kind === 'say' && redoSay.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named > 0');
const text = redoSay?.kind === 'say' ? redoSay.text : undefined;
const parts = JSON.stringify(text);
expect(parts).toContain('按你的意见复核了');
/* 摆的是件上那一行自己的话与名字 —— 对话区与件上说的是同一件事,就该是同一句话 */
expect(parts).toContain('evidence_note');
expect(parts).toContain('parameter_name');
expect(parts).toContain('substitution_difference_item_redo_scope_named');
});
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
expect(ASSESSMENT_PHASES).toHaveLength(5);
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
const all = ASSESSMENT_PHASES.flatMap((p) => p.items);
expect(new Set(all).size).toBe(all.length);
expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort());
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[8];
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();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,13 @@
/** 清单与案例模型共用的常量 —— 单独成文件,免得 manifest ↔ case-model 循环 import 撞 TDZ。 */
export const ACTIVITY_ID_TEXT = 'part-substitution-assessment';
/** 活动自持单据的域名 */
/** 活动自持单据(案卷)的域名 */
export const D = 'sub_eval';
/** GOV 差异规则版本 —— 随包冻结,随每条结论落账(rule_version) */
export const RULE_VERSION = 'R-2026.07';
export const TOOL_COMPARE = 'part-substitution-assessment.compare-parameters';
export const TOOL_RECOMMEND = 'part-substitution-assessment.recommend';
/*
* `TOOL_COMPARE` / `TOOL_RECOMMEND` 两个常量已随 2026-08-29 的整体重写退役
* (spec `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §1/§8):
* 逐项四态改由 S2 唯一一次 `analyze` 出场判定,推荐等级改由配方按 §3.5 的规则算 ——
* 两枚工具已在 Task 10 于工具注册表里停用(墓志铭见 `apps/api/src/app/tools/first-party.ts`)。
* 案例模型(`case-model.ts`)全程零工具调用,没有第二处字面量引用留着。
*/
@@ -2,8 +2,9 @@
* ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
* 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 五张 HTML 报告模版。
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 案例模型 + 说明书 + 步骤剧本(全 `auto`,零 `guide`)+ 台账声明 + 三张 HTML 报告模版
* (**0 条技能** —— 2026-08-29 整体重写后全程无 agent 临场编排,技能戳随两枚工具、
* 两张 DMN 一并退役)。这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
File diff suppressed because it is too large Load Diff
@@ -1,55 +0,0 @@
/**
* ACT-WORK-05 差异评估的技能 —— 载方法,不载权力。
* 剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、拿不准时按哪条走」。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const ASSESSMENT_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('substitution.normalize-parameters'),
label: '把多来源资料整成可比对的标准参数对照表',
applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。',
method: [
'参数集是独立子表 `parts.parameter`(P-08 的结构子表),一行一参数,按 `internal_pn` 归属主记录:列是 param_name / param_value / param_unit / constraint_type / param_note。',
'对照表按**原 PN 名下的那几行参数名**为骨架:原 PN 有的参数,候选名下没有同名那一行 → 候选值写「未披露」(它会成为 UNKNOWN);',
'候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。',
'单位以原 PN 那一行的 param_unit 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。',
'约束类型以原 PN 那一行的 constraint_type 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。',
'**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['标准化参数子表结构', '硬/软约束口径'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('substitution.difference-judgement'),
label: '逐项四态由工具算,证据与影响由你写',
applicability: '对照表齐了,要出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 时。',
method: [
'四态**不许自己判**:调 `part-substitution-assessment.compare-parameters`,把每个候选的全部参数对一次性传进去(candidates 数组,一次一批)。',
'每对参数都带 `evidenceRef`(值从哪份记录来:「P-08」「P-10 Rev H §6.5」「数据工单 <单号> · 确认人 <人>」)——硬约束没有证据引用,工具不判 MATCH。',
'客户在应用场景里给了边界(如「输入上限 40V」)就写进那一对的 `requirement`(`>= 40`),候选不满足直接 MISMATCH。',
'工具出参照抄进 `substitution.difference_item`:difference_status / compare_result / risk_level 一字不改;',
'`impact_statement` 你写:MATCH 写「无」,其余说清对应用 / 验证 / 报价的影响;UNKNOWN 必须写 `unknown_disposition`(挂验证任务号或补来源动作),不许悬空。',
'推荐等级同样由工具定(`part-substitution-assessment.recommend`,传每个候选的 counts),`recommendation_reason` 照工具的 reason 扩写,不许把不推荐改成推荐。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['四态口径', '推荐等级四档'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('substitution.landing'),
label: '落账只写分析层,客户批准不碰',
applicability: '审核通过之后拟 P-11 / 回写 P-01 时。',
method: [
'`analysis_status` 从 `ai_draft` 推到 `fae_confirmed`;`customer_approval_status` 由独立审批流维护 —— 本活动**没有这一列的写权**,写了会被拒。',
'P-01 回写五项:risk_level(取各候选里最高的)、verification_requirement(把所有验证任务号列全)、output_ref(替代分析报告号)、next_action(「提交 ACT-GATE-03 评审放行」)、current_stage 写 `gate03`。',
'规则版本随结论落账:每条 P-11 主记录与差异明细都写 rule_version(工具出参的 ruleVersion),中途规则更新不影响进行中的评估。',
'被打回重做时改的是**台账本身**(理由 / 依据 / 去向),不是把同一份原样再递一遍。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['analysis_status 与 customer_approval_status 分离'],
version: '1.0.0',
},
];
@@ -0,0 +1,18 @@
/**
* 徽标别名的生成器(与 `report.ts` 里那一段同一套逻辑;report.ts 另有一份 —— 并成一处记在 debt)。
*
* **2026-08-27 实测事故**:手写的 `.jc-tag.v-pin_to_pin::after{…}` 永远匹配不上,件上显示「待定」,
* 而台账里 `substitution_type` 明明是 `pin_to_pin`。`data-class-of` 投影出的 class 走 `view-render.ts` 的
* `classToken`(只留 `[a-z0-9-]`,**吃掉下划线**,封顶 24 字),所以选择器一律从 tally 值**算**出来。
*/
/** 必须与 `classToken` 一字不差:小写、只留 `[a-z0-9-]`、封顶 24 字。 */
export const vtok = (v: string): string => v.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 24);
/** `[值, 人读名, 可选颜色声明]`:值与文案成对写在一处 */
export type BadgeSpec = readonly [value: string, label: string, color?: string];
export const badgeCss = (selector: string, specs: readonly BadgeSpec[]): string =>
specs
.map(([v, label, color]) => {
const cls = `${selector}.v-${vtok(v)}`;
return color === undefined ? `${cls}::after { content: "${label}"; }` : `${cls}::after { content: "${label}"; }\n${cls} { ${color} }`;
})
.join('\n');
@@ -0,0 +1,440 @@
/**
* 运营中心 L2 沙箱视图 —— ACT-WORK-05「关键台账数据」看板(2026-08-27 初版,2026-08-29 Task 9 重绑)。
*
* **2026-08-29 Task 9「运营中心看板重绑」**:2026-08-29 整体重写(spec
* `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`)把九步案例模型砍成四步、
* 台账从旧的 `sub_eval.pn_check`/`sub_eval.verdict` 三张单据 + `demand.request`/`customer.profile`/
* `parts.document` 五张外域表,换成本活动自持的 `sub_eval.case`/`sub_eval.part` 两张 + 新增的
* `parts.chip_type`/`parts.type_param` 两张类型字典 —— 这份看板原样引着已退役的五张地址,
* 装包会被静态审拦下。本轮把 `LEDGER_BOARD_DATA` 与 `LEDGER_BOARD` 一并**重绑**到新九张表:
* 案卷(`sub_eval.case`)→ 两颗料(`sub_eval.part`)→ 类型与字段字典(`parts.chip_type`/
* `parts.type_param`)→ 参数对照(`parts.profile`/`parts.parameter`)→ 关系与明细
* (`substitution.relation`/`substitution.difference_item`)→ P-06(`validation.record`,
* 含同料号历史 `validation.history`)。P-01 需求主台账 / P-10 原厂资料两个板块随之整段撤下 ——
* 本活动这次重写已经不碰这两张表(见 manifest.ts「这个活动不碰 P-01 / P-10 / 客户档案」)。
* 新设计是**一次评估恰一对一候选**(不再是「多候选,部分放弃」),`substitution.relation`
* 一单一行、`sub_eval.part` 一单两行(原/候选各一),「候选」不再是需要 `data-rows` 逐个复制的
* 集合,页头 / P-08 两处原来的 `.cands`(多候选并排)、`.aband`(放弃候选的候选行)两个概念
* 一并撤下 —— 新模型里没有「本轮放弃的候选」这件事。
*
* 版式与样式**沿用**(重绑不是重做):页头 / 数据来源图例 / 分层图例 / 表格样式 / 三态横幅
* 这几段版面语言原样保留;`.tbl-wrap` 放开横滑、徽标文案由 `data-class-of` 投影 + 生成式
* `::after` 出的既有约定不变。溯源口径参照 `…-数据溯源.md`(该文档写在旧九步年代,
* 域名对照以本文件为准)。
*
* **它摆的是最近一次已放行实例的落账快照**(用户定):行由基座按 `LEDGER_BOARD_DATA` 的声明从**正式表**圈出 ——
* 每个 `data-rows` 键都是声明里的一个 `as`,不是整表;`<as>.<列>` 是那一集合首行的标量,`<as 去符号>_rows` /
* `_<列>_<值>` 是基座数出来的读数(`apps/api/src/app/activity-ops/view-scope.ts`)。模版仍**不算不判**:
* MATCH 行淡显、三态横幅,全是 `data-class-of` 投出的 class 交样式表。
* 无脚本、无外链(过安装静态审);报高脚本由服务端出口注入。
*/
import type { ViewData } from '@contracts';
import { badgeCss } from './badge.ts';
import { D } from '../constants.ts';
/**
* 案号 `W05-<开案时刻 14 位>`(与说明书让模型铸 `eval_ref` 的写法同源);行集按案号 → 案卷 →
* 两颗料 → 类型字典(按案卷锁定的 `chip_type_id` 查)→ 主数据 / 参数(按两颗料的 `internal_pn` 查)→
* 关系(按案号查,一单一行)→ 明细(按关系的主键查,`by.from` 必须指向**前面**已求出的集合 ——
* 明细依赖关系的主键,所以 `rows` 数组里关系排在明细**之前**,即便「关系」是这一签最终的结论、
* 「明细」是支撑结论的逐条证据,版面上想先摆哪个都行,数据依赖顺序是另一件事)→ P-06(按案号查,
* 一单固定一条;同料号历史另开一集,按两颗料的 `internal_pn` 查、排除本单自己那一条)。
*/
export const LEDGER_BOARD_DATA: ViewData = {
case: { ledger: `${D}.case`, key: 'eval_ref', prefix: 'W05-' },
rows: [
{ as: `${D}.case`, ledger: `${D}.case`, by: { column: 'eval_ref', from: 'case' } },
{ as: `${D}.part`, ledger: `${D}.part`, by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }, { field: 'internal_pn', dir: 'asc' }] },
{ as: 'parts.chip_type', ledger: 'parts.chip_type', by: { column: 'type_id', from: `${D}.case.chip_type_id` } },
{ as: 'parts.type_param', ledger: 'parts.type_param', by: { column: 'type_id', from: `${D}.case.chip_type_id` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: `${D}.part.internal_pn` } },
{ as: 'parts.parameter', ledger: 'parts.parameter', by: { column: 'internal_pn', from: `${D}.part.internal_pn` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
{
as: 'substitution.difference_item',
ledger: 'substitution.difference_item',
by: { column: 'substitution_relation_id', from: 'substitution.relation.substitution_relation_id' },
orderBy: [{ field: 'difference_item_id', dir: 'asc' }],
},
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'eval_ref', from: 'case' } },
/* 同料号在别单 / 别处留下的验证记录:本次评估的历史证据引用,排除本单自己那一条 */
{
as: 'validation.history',
ledger: 'validation.record',
by: { column: 'validation_internal_pn', from: `${D}.part.internal_pn` },
not: { column: 'eval_ref', from: 'case' },
orderBy: [{ field: 'validation_id', dir: 'desc' }],
},
],
};
/* ==================== 目标件样式(基座壳与版面语言沿用旧版;域名相关内容按新九张表改写) ==================== */
const TARGET_CSS = `
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--data:#3370ff; --data-soft:#e1eaff; --data-line:#c3d6ff;
--human:#d97706; --human-soft:#fdf1dc; --human-line:#f0d8a8;
--ledger:#16a34a; --ledger-soft:#e6f6ec; --ledger-line:#b7e5c6;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#fff;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{max-width:none;margin:0;padding:0}
/* 头部 */
.mast{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:22px 26px;position:relative;overflow:hidden}
.mast::before{content:"";position:absolute;inset:0 0 auto 0;height:4px;background:linear-gradient(90deg,var(--brand) 0%,var(--brand-deep) 100%)}
.mast .kick{font-size:11px;letter-spacing:.12em;color:var(--ink-4);font-weight:800}
.mast h1{margin:4px 0 6px;font-size:23px;font-weight:800;line-height:1.3}
.mast h1 .code{font-family:var(--mono);font-size:14px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash);border:1px solid var(--brand-soft);border-radius:8px;padding:3px 10px;margin-right:10px;vertical-align:3px}
.mast .sub{font-size:13px;color:var(--ink-3)}
.mast .sub b{color:var(--ink-2)}
/* 数据来源图例 */
.legend{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0;font-size:12px;color:var(--ink-3)}
.legend .li{display:inline-flex;align-items:center;gap:6px;background:#fff;border:1px solid var(--line-soft);border-radius:999px;padding:3px 12px}
.dot{width:8px;height:8px;border-radius:50%;flex:none}
.dot.full{background:var(--brand)}
.dot.half{background:var(--amber)}
/* 分层图例 —— 新九张表收成四层(P-01/P-10 两层随重写撤下) */
.layers{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:14px 0 18px}
.layer{background:#fff;border:1px solid var(--line-soft);border-radius:11px;padding:11px 14px;font-size:12px;color:var(--ink-2);border-top:3px solid var(--brand)}
.layer b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px}
.layer .lg{font-family:var(--mono);font-weight:800;color:var(--brand-deep)}
.layer.core-out{border-top-color:var(--ledger)} .layer.core-out .lg{color:var(--ledger)}
.layer.evid{border-top-color:var(--human)} .layer.evid .lg{color:var(--human)}
.layer.loop{border-top-color:var(--amber)} .layer.loop .lg{color:var(--amber)}
/* 区块 */
section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:18px 22px;margin-bottom:16px}
.sec-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}
.sec-head h2{margin:0;font-size:15.5px;font-weight:800}
.sec-head h2 .lid{font-family:var(--mono);font-size:12px;color:var(--brand-deep);background:var(--brand-wash);border:1px solid var(--brand-soft);border-radius:6px;padding:1px 8px;margin-right:8px}
.ltag{font-size:10.5px;font-weight:800;padding:2px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.ltag.in{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
.ltag.out{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
.ltag.evid{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
.ltag.loop{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
.sec-head .why{font-size:12px;color:var(--ink-4)}
.sec-head .cnt{margin-left:auto;font-size:11.5px;color:var(--ink-4);font-family:var(--mono)}
/* 表格 —— 表宽超出就得能横滑(本仓 2026-08-23 定) */
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow-x:auto;overflow-y:hidden}
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
table{width:100%;border-collapse:collapse;font-size:12.5px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
td{padding:9px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
tbody tr{transition:background .15s}
tbody tr:hover{background:var(--brand-wash)}
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.pn{font-weight:700}
.cust{font-weight:700}
/* 状态 pill */
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap;border:1px solid transparent}
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
.st.unknown{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
.st.cond{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
.st.gray{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
.st.warn{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
/* 参数集摘要 chips */
.pchips{display:flex;flex-wrap:wrap;gap:4px}
.pchips span{font-size:11px;font-family:var(--mono);background:var(--soft);border:1px solid var(--line-soft);border-radius:6px;padding:1px 7px;color:var(--ink-2);white-space:nowrap}
/* 次要台账条 */
.minor{display:flex;gap:10px;flex-wrap:wrap}
.minor .m{flex:1;min-width:220px;border:1px solid var(--line-soft);border-radius:10px;padding:11px 14px;font-size:12px;color:var(--ink-2)}
.minor .m b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:3px}
.minor .m .lid{font-family:var(--mono);color:var(--ink-4);font-weight:700;margin-right:6px}
footer{margin-top:18px;font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:900px){.layers{grid-template-columns:repeat(2,1fr)}}
`;
/* ==================== 绑定层:徽标别名 —— 只取值,不算不判 ==================== */
const OK = 'color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4;';
const MISS = 'color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5;';
const UNKNOWN = 'color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line);';
const COND = 'color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft);';
const GRAY = 'color:var(--ink-3);background:var(--soft);border-color:var(--line);';
const BIND_CSS = `
/* 徽标:值域各归各族(同一个词在不同列意思不同),文案与颜色由 data-class-of 投出的 v-<token> 决定;
没投出 class 的格子摆「—」 */
.st::after{content:"—"}
${badgeCss('.st.lc', [
['active', 'Active', OK],
['nrnd', 'NRND', MISS],
['eol', 'EOL', MISS],
['discontinued', '停产', MISS],
['risk', '风险', UNKNOWN],
])}
${badgeCss('.st.rec', [
['recommended', '推荐', OK],
['conditional', '条件推荐', UNKNOWN],
['not_recommended', '不推荐', MISS],
])}
/* analysis_status 这次重写没有再声明 tally(本活动只写这一个固定值,分析层与客户批准层分离) */
${badgeCss('.st.an', [
['fae_confirmed', 'FAE已确认', COND],
])}
${badgeCss('.st.rk', [
['low', '低', OK],
['medium', '中', UNKNOWN],
['high', '高', MISS],
])}
${badgeCss('.st.dst', [
['match', 'MATCH', OK],
['mismatch', 'MISMATCH', MISS],
['unknown', 'UNKNOWN', UNKNOWN],
['conditional', 'CONDITIONAL', COND],
])}
/* validation_status 同样没有声明 tally(本活动只写 not_started —— 验证是否通过不是这个活动的事) */
${badgeCss('.st.vs', [
['not_started', '未开始', GRAY],
])}
/* 在库状态(sub_eval.part.exists_status)—— 这次重写只剩两档(found/absent),
「本单建档」「已放弃」两档随旧的多候选设计一起退役 */
${badgeCss('.st.ex', [
['found', '在库直读', COND],
['absent', '缺库', MISS],
])}
${badgeCss('.st.rl', [
['original', '原 PN', COND],
['candidate', '候选', GRAY],
])}
/* 纯文字的枚举列:只补文案 */
.enumtxt::after{content:"—"}
${badgeCss('.enumtxt.con', [
['hard', '硬约束'],
['soft', '软约束'],
])}
/* validation_type 这次重写多了 parametric 一档(S2 的验证记录投影固定写它) */
${badgeCss('.enumtxt.vt', [
['pin_to_pin', 'Pin-to-Pin'],
['functional', '功能'],
['reliability', '可靠性'],
['certification', '认证'],
['retest', '复测'],
['parametric', '参数验证'],
])}
/* 参数瓷贴:参数名淡、值粗、单位紧跟 */
.pchips i{font-style:normal;color:var(--ink-4);margin-right:3px}
.pchips b{font-weight:700}
.pchips em{font-style:normal;margin-left:1px}
.pchips:empty::after{content:"参数集未登记";font-size:11px;color:var(--ink-4)}
/* 料号格里的两枚小徽(角色 / 在库状态) */
.pnrow{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}
.pnrow .st{font-size:10px;padding:0 7px}
/* 差异明细:MATCH 行淡显(不藏:那也是比对过的事实),非 MATCH 才是要看的 */
tr.v-match td{opacity:.55}
/* P-06 历史行:首格挂一枚「历史」灰签 */
tbody.hist td:first-child::after{content:"历史";display:inline-block;margin-left:6px;font-size:10px;font-weight:800;padding:0 6px;border-radius:999px;color:var(--ink-3);background:var(--soft);border:1px solid var(--line);font-family:Inter,ui-sans-serif,system-ui,sans-serif}
td .sub{display:block;font-size:11px;color:var(--ink-3);font-weight:400}
td .sub:empty{display:none}
/* 三态横幅:none = 本活动还没有已放行实例;missing = 有实例但案号在正式表里查无 */
.notice{display:none;align-items:flex-start;gap:8px;margin:0 0 16px;padding:12px 16px;border-radius:12px;font-size:13px;line-height:1.6}
.notice.none{background:var(--soft);border:1px solid var(--line);color:var(--ink-2)}
.notice.missing{background:var(--amber-soft);border:1px solid var(--amber-line);color:var(--amber)}
.page.v-none .notice.none{display:flex}
.page.v-missing .notice.missing{display:flex}
.page.v-none .layers,.page.v-none section,.page.v-none .mast .sub.case,.page.v-none .mast .sub.verdict{display:none}
.page.v-missing section,.page.v-missing .mast .sub.verdict{display:none}
`;
const th = (...cols: string[]): string => `<thead><tr>${cols.map((c) => `<th>${c}</th>`).join('')}</tr></thead>`;
export const LEDGER_BOARD = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ACT-WORK-05 差异评估 · 关键台账数据 · SS02 需求分析与方案匹配</title>
<style>${TARGET_CSS}${BIND_CSS}</style>
</head>
<body>
<div class="page" data-class-of="board:state">
<header class="mast">
<div class="kick">SS02 需求分析与方案匹配 · 活动台账视图</div>
<h1><span class="code">ACT-WORK-05</span>关键台账数据</h1>
<p class="sub case">视角:本活动最近一次已放行实例 <b data-slot="case:instanceNo"></b>(<b data-slot="case:openedAt"></b> 开案 · <b data-slot="case:releasedAt"></b> 放行 · 负责人 <b data-slot="case:owner" data-blank></b>)的落账快照 · 案号 <b class="mono" data-slot="case:ref"></b> · 评估基准 <b data-slot="substitution.relation.original_internal_pn"></b> → 候选 <b data-slot="substitution.relation.candidate_internal_pn"></b> · 芯片类型 <span data-slot="parts.chip_type.type_name" data-blank></span> · 规则版本 <b data-slot="substitution.relation.rule_version"></b></p>
<p class="sub verdict">评估结论:<span data-slot="${D}.case.summary" data-blank></span></p>
<div class="legend">
<span class="li"><span class="dot full"></span>正式台账直读 —— 放行落地后的事实,按案号圈出这一单的行</span>
<span class="li"><span class="dot half"></span>同料号的历史验证记录(别单 / 线下留下的,作本次评估的证据引用)</span>
</div>
</header>
<div class="notice none">本活动还没有已放行的实例 —— 有一单放行后,这里摆它的关键台账落账快照。</div>
<div class="notice missing">实例 <b data-slot="case:instanceNo"></b> 已放行,但案号 <b class="mono" data-slot="case:ref"></b> 在正式表里查无 —— 多半是起单时关了「落真库」(主数据只留在活动缓存),这一单没有可摆的落账数据。</div>
<!-- 重要性分层 -->
<div class="layers">
<div class="layer"><b><span class="lg">P-08</span> 芯片主数据</b>核心输入 · 评估原材料,查无即硬阻塞</div>
<div class="layer evid"><b><span class="lg">字段字典</span> 类型与核心参数</b>比对基准 · 按芯片类型定义要比的字段与硬软约束</div>
<div class="layer core-out"><b><span class="lg">P-11</span> 替代批准关系库</b>核心产出 · 结论与明细的落账地</div>
<div class="layer loop"><b><span class="lg">P-06</span> 样品验证记录</b>闭环去向 · UNKNOWN 的实测收口</div>
</div>
<!-- P-08 -->
<section id="p08">
<div class="sec-head">
<h2><span class="lid">P-08</span>芯片主数据台账</h2>
<span class="ltag in">核心输入 · R(查无 → 数据工单创建)</span>
<span class="why">原 PN 与候选 PN 的物料基准 + 标准化参数集(子表 P-08 参数),逐项比对的原材料</span>
<span class="cnt">正式表 · <i data-slot="parts_profile_rows"></i> 条(本次评估相关)</span>
</div>
<div class="tbl-wrap">
<table>
${th('内部料号', '品牌', '规格描述', '生命周期', 'Datasheet 版本', '标准化参数集')}
<tbody data-rows="parts.profile">
<tr>
<td class="pn mono"><span data-slot="internal_pn"></span><span class="pnrow" data-rows="${D}.part" data-by="internal_pn"><i class="st rl" data-class-of="role"></i><i class="st ex" data-class-of="exists_status"></i></span></td>
<td data-slot="brand_name" data-blank></td>
<td data-slot="specification_description" data-blank></td>
<td><span class="st lc" data-class-of="lifecycle_status"></span></td>
<td class="mono" data-slot="datasheet_version" data-blank></td>
<td><div class="pchips" data-rows="parts.parameter" data-by="internal_pn"><span><i data-slot="param_name"></i><b data-slot="param_value" data-blank></b><em data-slot="param_unit" data-blank></em></span></div></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 字段字典 -->
<section id="dict">
<div class="sec-head">
<h2><span class="lid">字段字典</span>芯片类型与核心参数</h2>
<span class="ltag evid">比对基准 · R</span>
<span class="why">两颗料共同归属的芯片类型,及该类型定义的核心参数字段(名字 / 说明 / 硬软约束 / 单位)</span>
<span class="cnt">类型 · <b data-slot="parts.chip_type.type_name" data-blank></b> · 字段 <i data-slot="parts_type_param_rows"></i> 条</span>
</div>
<div class="tbl-wrap">
<table>
${th('参数名', '参数说明', '约束', '参考单位')}
<tbody data-rows="parts.type_param">
<tr>
<td class="cust" data-slot="param_name"></td>
<td class="sub" data-slot="param_desc" data-blank></td>
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
<td class="mono" data-slot="param_unit" data-blank></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- P-11 -->
<section id="p11">
<div class="sec-head">
<h2><span class="lid">P-11</span>替代选型与批准关系库 · 替代关系主记录</h2>
<span class="ltag out">核心产出 · R+W</span>
<span class="why">原 PN → 候选 PN 一条关系记录;推荐等级 / 风险 / 摘要由规则算出,客户批准状态为只读边界,本活动不写</span>
<span class="cnt">正式表 · <i data-slot="substitution_relation_rows"></i> 条</span>
</div>
<div class="tbl-wrap" style="margin-bottom:14px">
<table>
${th('替代关系 ID', '原 → 候选', '推荐等级', '分析状态', '风险', '差异摘要 / 理由', '验证要求 / 回链', '规则版本')}
<tbody data-rows="substitution.relation">
<tr>
<td class="mono" data-slot="substitution_relation_id"></td>
<td class="mono"><span data-slot="original_internal_pn"></span> → <b data-slot="candidate_internal_pn"></b></td>
<td><span class="st rec" data-class-of="recommendation_level"></span></td>
<td><span class="st an" data-class-of="analysis_status"></span></td>
<td><span class="st rk" data-class-of="risk_level"></span></td>
<td><span data-slot="difference_summary" data-blank></span><span class="sub" data-slot="recommendation_reason" data-blank></span></td>
<td class="mono"><span data-slot="validation_id" data-blank></span><span class="sub" data-slot="verification_requirement" data-blank></span></td>
<td class="mono" data-slot="rule_version" data-blank></td>
</tr>
</tbody>
</table>
</div>
<div class="sec-head" style="margin-bottom:10px">
<h2 style="font-size:13.5px"><span class="lid">P-11</span>差异明细子表(按字段字典逐项比对得出;MATCH 项淡显)</h2>
<span class="cnt"><i data-slot="substitution_difference_item_rows"></i> 条 · MATCH <i data-slot="substitution_difference_item_difference_status_match"></i> · MISMATCH <i data-slot="substitution_difference_item_difference_status_mismatch"></i> · UNKNOWN <i data-slot="substitution_difference_item_difference_status_unknown"></i> · CONDITIONAL <i data-slot="substitution_difference_item_difference_status_conditional"></i></span>
</div>
<div class="tbl-wrap">
<table>
${th('参数项', '约束', '原值 → 候选值', '单位', '结论', '风险', '判断依据', '影响说明')}
<tbody data-rows="substitution.difference_item">
<tr data-class-of="difference_status">
<td data-slot="parameter_name"></td>
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
<td class="mono"><span data-slot="original_value" data-blank></span> → <span data-slot="candidate_value" data-blank></span></td>
<td data-slot="parameter_unit" data-blank></td>
<td><span class="st dst" data-class-of="difference_status"></span></td>
<td><span class="st rk" data-class-of="risk_level"></span></td>
<td class="mono" data-slot="evidence_note" data-blank></td>
<td data-slot="impact_statement" data-blank></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- P-06 -->
<section id="p06">
<div class="sec-head">
<h2><span class="lid">P-06</span>样品交付与芯片验证记录台账</h2>
<span class="ltag loop">闭环去向 · R + 条件写(不可创建,只能实测产生)</span>
<span class="why">UNKNOWN / CONDITIONAL 的收口处;一单固定一条</span>
<span class="cnt">本单挂的 · <i data-slot="validation_record_rows"></i> 条 · 同料号历史 · <i data-slot="validation_history_rows"></i> 条</span>
</div>
<div class="tbl-wrap">
<table>
${th('验证 ID', '验证料号', '验证类型', '测试条件', '结果 / 状态', '后续动作')}
<tbody data-rows="validation.record">
<tr>
<td class="mono" data-slot="validation_id"></td>
<td class="mono" data-slot="validation_internal_pn"></td>
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
<td data-slot="test_condition" data-blank></td>
<td><span class="st vs" data-class-of="validation_status"></span></td>
<td data-slot="next_validation_action" data-blank></td>
</tr>
</tbody>
<tbody class="hist" data-rows="validation.history">
<tr>
<td class="mono" data-slot="validation_id"></td>
<td class="mono" data-slot="validation_internal_pn"></td>
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
<td data-slot="test_condition" data-blank></td>
<td><span class="st vs" data-class-of="validation_status"></span></td>
<td data-slot="next_validation_action" data-blank></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 次要台账 -->
<section id="minor">
<div class="sec-head">
<h2>次要台账(只读引用,不进主看板)</h2>
</div>
<div class="minor">
<div class="m"><b><span class="lid">GOV</span>评估规则库</b>四态判定与推荐等级口径;本次锁定版本 <span class="mono" data-slot="substitution.relation.rule_version"></span>,随结论一并落账。</div>
</div>
</section>
<footer>
<span>SS02 · ACT-WORK-05 差异评估 · 关键台账数据视图</span>
<span style="font-family:var(--mono)">数据来源:正式台账(parts / substitution / validation / sub_eval)· 实例 <span data-slot="case:instanceNo"></span> · 案号 <span data-slot="case:ref"></span></span>
</footer>
</div>
</body>
</html>
`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,481 @@
/**
* ACT-WORK-05 三张工单件的设计系统(2026-08-31 视图层整体重设计。用户定:
* 「中间这一块不要搞的像后台管理端,要像给客户使用的 C 端界面;可以按主题加 SVG 图形,
* 可以有动画,加强交互 —— 只保留功能,视图层可以推翻重新设计」)。
*
* 设计语言:「精密检测报告」——
* · 题头是一块深色电路板夜景(hero):芯片封装、铜引脚、流动的走线信号,
* 三张件各有一幕对应自己职能的场景(建档台 / 对决量尺 / 落账席);
* · 正文回到瓷白纸面,报告化排版:丝印体小标(silkscreen)、mono 料号、
* 引脚号式的序号列 —— 用电子行业自己的版面语汇,不借后台管理端的灰表格;
* · 动画只做 transform / opacity(信号点流动、晶粒呼吸、印章环慢转、入场浮起),
* prefers-reduced-motion 下全部静止 —— 与工作台动画同一条纪律。
*
* 旧的 target-style.ts(逐字节抽自目标件的三段样式)随本次重设计退役:目标件是
* 「照着做」时代的锚,这一版是活动自己的设计,样式只此一处真源。
*
* **这一整段活在 TS 模板字符串里 —— 不许出现反引号**(一个反引号就把串截断,
* esbuild 报在几行之外;本仓踩过)。注释里也别写星号斜杠连排。
*
* 几条被包内测试钉死的**机制规则逐字保留**,别「顺手整理」:
* · 序号计数器五连(.jc-tbl-wrap table, .tbl-wrap table 成对出现);
* · table:not(.on) tr.jc-row-static { display: none; }(种子行只在编辑态露面);
* · .jc-gate-bar:has(.jc-gate-text:empty) 显隐(gate_reason 是中文自由文本,
* data-class-of 在它上面永远算不出 v-token,显隐只认 data-blank 出的空文本节点);
* · .jc-tbl-wrap 不许 overflow:hidden(表宽超出要能横着滑)。
*/
/** 件① 数据工单的强调色 —— 数据蓝(与治理台「数据」语义同族) */
export const ACCENT_DATA = `
:root{
--acc:#2e6bff; --acc-deep:#1f52d6; --acc-soft:#e3ecff; --acc-line:#c2d5ff; --acc-wash:#f3f7ff;
--glow:#6fa1ff; --glow-line:rgba(111,161,255,.36); --hero-tint:rgba(46,107,255,.34);
}
`;
/** 件②③ 验收工单的强调色 —— 验收绿 #0aa294(2026-08-27 用户定:与对话区验收卡同色) */
export const ACCENT_LEDGER = `
:root{
--acc:#0aa294; --acc-deep:#07857a; --acc-soft:#dcf5f1; --acc-line:#aee5dd; --acc-wash:#f0fbf9;
--glow:#2fd4c2; --glow-line:rgba(47,212,194,.34); --hero-tint:rgba(10,162,148,.32);
--ledger:#0aa294;
}
`;
/**
* **验收工单那一副的强调色**(2026-08-31 用户:「标题那一块需要是验收卡的那种绿色」)——
* 件① 一张两副面孔:数据工单蓝、验收工单绿。整套令牌从 `ACCENT_LEDGER` **现改选择器**得来,
* 数值只有一处真源(件②③ 与对话区验收卡同一枚 #0aa294);题头那条浅色渐变是写死的蓝白,
* 单独给一条绿的override —— `.card.v-accept`(0,2,0)比 `:root`(0,1,0)特异,先后顺序不吃亏。
*/
export const ACCENT_ACCEPT = `${ACCENT_LEDGER.replace(':root{', '.card.v-accept{')}
.card.v-accept.hd-light .card-head{background:linear-gradient(120deg,#e9f9f5 0%,#fff 55%,#e3f7f1 100%);border-bottom-color:var(--acc-deep)}
`;
export const SHELL_CSS = `
/* ================= 令牌与底 ================= */
:root{
--ink:#182430; --ink-2:#3d4c5c; --ink-3:#67788a; --ink-4:#97a5b3;
--line:#dbe3ea; --line-soft:#e8edf3; --soft:#f5f8fa; --paper:#edf1f5;
--board:#0e1b27; --board-2:#15263a;
--cu:#d99a5b; --cu-deep:#b57632;
--ok:#1e9e56; --ok-soft:#e4f7ec;
--danger:#dd4a3a; --danger-soft:#feecea;
--amber:#b07a10; --amber-soft:#fdf3d8;
--cond:#5661f0; --cond-soft:#e9ebfe;
--hero-ink:#f4f8fc; --hero-dim:rgba(226,238,248,.62);
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--paper);color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0}
.mono{font-family:var(--mono)}
i{font-style:normal}
/* ================= 题头:电路板夜景 hero ================= */
.card-head{
position:relative;display:flex;flex-direction:column;gap:12px;padding:20px 22px 22px;color:var(--hero-ink);
background:
repeating-linear-gradient(0deg, rgba(255,255,255,.03) 0 1px, transparent 1px 28px),
repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 28px),
radial-gradient(900px 340px at 18% -40%, var(--hero-tint), transparent 62%),
linear-gradient(158deg, #13212f 0%, var(--board) 56%, #0f1d2b 100%);
}
.hero-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.hero-sys{flex:1 1 auto;min-width:0;font-size:10.5px;font-weight:800;letter-spacing:.17em;color:var(--hero-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hero-chips{flex:none;display:flex;gap:6px}
.hero-tag{font-size:11px;font-weight:800;padding:2px 11px;border-radius:999px;color:#fff;background:var(--acc);box-shadow:0 4px 14px var(--glow-line);white-space:nowrap}
.hero-step{font-size:11px;font-weight:700;padding:2px 11px;border-radius:999px;color:var(--hero-dim);border:1px solid rgba(255,255,255,.22);white-space:nowrap}
.hero-title{margin:0;font-size:23px;font-weight:800;line-height:1.25;letter-spacing:.01em;color:#fff}
.hero-meta{font-size:11.5px;color:var(--hero-dim)}
.hero-meta b{font-weight:700;color:#fff}
/* ── hero 舞台:芯片铭牌 + 信号走线 ── */
.stage{position:relative;display:flex;align-items:stretch;justify-content:center;gap:clamp(20px,7vw,72px);padding:14px 6px 6px}
.stage::before{content:"";position:absolute;left:5%;right:5%;top:52%;border-top:2px dashed var(--glow-line)}
.stage::after{content:"";position:absolute;top:52%;left:5%;width:9px;height:9px;margin-top:-4px;border-radius:999px;background:var(--glow);box-shadow:0 0 14px 3px var(--glow-line),0 0 4px 1px var(--glow);animation:sig 3.4s linear infinite}
.stage-lab{padding-bottom:8px}
.stage-duel{padding-top:28px}
.hchip{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:6px;flex:0 1 176px;min-width:128px;padding:13px 12px 11px;border-radius:14px;background:rgba(255,255,255,.97);color:var(--ink);box-shadow:0 14px 30px rgba(3,10,18,.5),inset 0 1px 0 rgba(255,255,255,.7)}
.hchip.v-absent{background:rgba(255,252,246,.97);outline:2px dashed var(--cu);outline-offset:-6px}
.hchip-role{font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--ink-4)}
.hchip-pn{font-size:14.5px;font-weight:800;color:var(--ink);overflow-wrap:anywhere;text-align:center;line-height:1.3}
.hchip-badges{display:flex;gap:6px;flex-wrap:wrap;justify-content:center}
.csvg{width:86px;height:auto;display:block}
.c-body{fill:var(--board-2);stroke:rgba(217,154,91,.55);stroke-width:1.4}
.c-pin{fill:var(--cu)}
.c-die{fill:var(--acc);opacity:.9;animation:breathe 2.8s ease-in-out infinite}
.c-dot{fill:rgba(255,255,255,.85)}
.duel-type{position:absolute;z-index:1;left:50%;top:-4px;transform:translateX(-50%);font-size:10.5px;font-weight:800;letter-spacing:.06em;color:var(--hero-dim);border:1px solid rgba(255,255,255,.2);border-radius:999px;padding:2px 12px;background:rgba(10,20,30,.55);white-space:nowrap}
.duel-type i{font-style:normal;color:#fff}
.hero-verdict{display:flex;justify-content:center;padding:2px 0 0}
/* ── hero 落账席(件③):印章 + 一句话结论 ── */
.stage-seal{position:relative;display:flex;align-items:center;justify-content:center;gap:22px;padding:12px 4px 4px;flex-wrap:wrap}
.seal{position:relative;flex:none;width:112px;height:112px;border-radius:999px;display:grid;place-items:center}
.seal::before{content:"";position:absolute;inset:0;border:1.6px dashed rgba(255,255,255,.34);border-radius:999px;animation:sealspin 16s linear infinite}
.seal-core{display:grid;place-items:center;padding:7px 11px;border-radius:999px;background:rgba(255,255,255,.97);box-shadow:0 12px 26px rgba(3,10,18,.5)}
/*
* **印章里的徽章按圈的内径收**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」——
* 浏览器里量的账:seal-core 139px 宽,而印章圈只有 112px,**溢出 26px**,
* 右边那句结论从 603px 起,正好被压住)。
* 病根是徽章按自己的字宽撑开、又带着 white-space:nowrap 缩不了,而印章圈是定死的圆。
* 治法两条一起:核心不许超过圈的内径;圈里的徽章字号收小、允许折行(圆里两行照样是圆)。
*/
.seal-core{max-width:calc(100% - 16px);padding:6px 8px}
.seal .rec.rec-lg{font-size:12px;letter-spacing:0;padding:5px 8px;white-space:normal;text-align:center;line-height:1.3}
.seal-sum{flex:1 1 240px;min-width:200px;max-width:420px;margin:0;font-size:13px;line-height:1.85;color:var(--hero-dim)}
.seal-sum:empty::after{content:"—"}
/* ── 浅色题头变体(件① 2026-08-31 用户:「标题背景还是改成之前的那种蓝色渐变」——
渐变取自原目标件 card-head 那一条,深色电路夜景只留给验收两件)── */
.hd-light .card-head{background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%);color:var(--ink);border-bottom:2px solid var(--ink)}
.hd-light .hero-sys{color:var(--ink-4)}
.hd-light .hero-title{color:var(--ink)}
.hd-light .hero-step{color:var(--ink-3);border-color:var(--line)}
.hd-light .hero-meta{color:var(--ink-3)}
.hd-light .hero-meta b{color:var(--ink)}
/* ================= 正文分区 ================= */
.card-bd{padding:20px 22px 8px}
.sec{display:flex;align-items:baseline;gap:9px;margin:26px 0 10px;flex-wrap:wrap}
.sec:first-child{margin-top:4px}
.sec-pin{flex:none;align-self:center;width:24px;height:24px;border-radius:8px;display:grid;place-items:center;background:var(--acc-soft);color:var(--acc-deep)}
.zic{width:13px;height:13px;display:block}
.sec-t{font-size:13.5px;font-weight:800;color:var(--ink);letter-spacing:.02em}
.sec-d{flex:1 1 200px;min-width:0;font-size:11.5px;color:var(--ink-3)}
/* 简报卡(做什么 / 纪律…) */
.brief{border:1px solid var(--line-soft);border-left:3px solid var(--acc);border-radius:10px;background:var(--acc-wash);padding:13px 16px;margin:0 0 4px}
.brief p{margin:0 0 7px;font-size:12.5px;line-height:1.75;color:var(--ink-2)}
.brief p:last-child{margin-bottom:0}
.brief b{color:var(--ink)}
.done,.need{display:inline-block;font-size:10.5px;font-weight:800;padding:0 8px;border-radius:999px;vertical-align:1px;margin-right:2px}
.done{color:var(--ok);background:var(--ok-soft)}
.need{color:var(--acc-deep);background:var(--acc-soft)}
/* 收尾指引 */
.flow{margin:24px 0 14px;padding:12px 16px;border-radius:10px;border:1px dashed var(--acc-line);background:var(--acc-wash);font-size:12.5px;color:var(--ink-2);line-height:1.8}
.flow b{color:var(--acc-deep)}
/* 页脚:丝印条 */
.card-foot{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 22px;border-top:1px solid var(--line-soft);background:var(--soft);font-size:11px;color:var(--ink-4);flex-wrap:wrap}
.foot-r{letter-spacing:.04em}
/* ================= 顶部「还差什么」红带(机制:显隐认 data-blank 的空文本节点) ================= */
.jc-gate-bar{display:flex;flex-direction:column;gap:5px;margin:0 0 16px;padding:11px 14px;border-radius:10px;background:var(--danger-soft);border:1px solid #f5c0ba;color:var(--danger);font-size:12.5px;line-height:1.7}
.jc-gate-bar b{font-weight:800}
.jc-gate-bar:has(.jc-gate-text:empty){display:none}
/* 都齐了就整条收起(2026-08-31 用户定)—— 开关是案卷真数据 gate_state(v-clear),
不看那句话空不空:gate_reason 清空时按列归型不落笔,旧话会一直留在草稿里 */
.jc-gate-bar.v-clear{display:none}
/* 逐块的缺项点名并进这一条(2026-08-31 用户:「页面上所有的问题都合并到最上面的还差什么」)——
与提交闸同源的基座读数,数值为 0 的那一行自己消失,只在待处理态出现 */
.jc-gate-sub{display:none;font-size:12px;color:#a03d34;padding-left:2px}
html.editable .jc-gate-sub{display:block}
html.editable .jc-gate-sub.v-0{display:none}
/* 缺项红带 —— 与提交闸同源的必填提示,只在待处理态出现 */
.jc-gapbar{display:none}
html.editable .jc-gapbar{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 13px;border-radius:9px;background:var(--danger-soft);border:1px solid #f5c0ba;color:var(--danger);font-size:12px;line-height:1.7}
.jc-gapbar b{font-weight:800}
.jc-gapbar span{color:#a03d34}
html.editable .jc-gapbar.v-0{display:none}
/* ================= 编辑闸与加行(只在待处理态露面) ================= */
html:not(.editable) .jc-editgate, html:not(.editable) .jc-addrow { display: none !important; }
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 10px;padding:8px 12px;border-radius:10px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
.jc-editgate-t{flex:1 1 auto;min-width:0}
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:999px;padding:6px 15px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s,box-shadow .16s}
.jc-egbtn:hover{border-color:var(--acc);color:var(--acc-deep);box-shadow:0 0 0 3px var(--acc-soft)}
.jc-egbtn:active{transform:scale(.96)}
.jc-egbtn.save{color:#fff;background:var(--acc);border-color:var(--acc)}
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
.jc-editgate.on{background:var(--acc-wash);border-color:var(--acc-line);color:var(--acc-deep)}
.jc-editgate.on .eg-open{display:none}
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
.jc-addrow{display:none;margin:8px 0 0}
.jc-tbl-wrap:has(table.on) + .jc-addrow { display: flex; align-items: center; gap: 8px; }
.jc-addrow button{cursor:pointer;border:1px dashed var(--acc-line);border-radius:999px;background:var(--acc-wash);color:var(--acc-deep);font:inherit;font-size:12px;font-weight:700;padding:6px 15px;transition:border-color .16s,box-shadow .16s}
.jc-addrow button:hover{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft)}
.jc-addrow button:active{transform:scale(.96)}
.jc-addrow em{font-style:normal;font-size:11px;color:var(--ink-4)}
/* ================= 报告表格 ================= */
.jc-tbl-wrap{border:1px solid var(--line-soft);border-radius:12px;background:#fff;overflow-x:auto;overflow-y:hidden}
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
table{width:100%;border-collapse:collapse;font-size:12.5px}
thead th{position:static;text-align:left;font-size:10.5px;font-weight:800;letter-spacing:.07em;color:var(--ink-4);background:var(--soft);padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
th.req::after{content:" *";color:var(--danger);font-size:10px}
tbody td{padding:10px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top;color:var(--ink-2)}
tbody tr:last-child td{border-bottom:0}
tbody tr:hover td{background:var(--acc-wash)}
td.rn,td.num{font-family:var(--mono);font-size:11px;color:var(--ink-4);white-space:nowrap}
td.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
td.cust{font-weight:700;color:var(--ink)}
td.desc,.desc{color:var(--ink-3);font-size:12px;line-height:1.6}
/* 序号列交给 CSS 计数器(沙箱零脚本,增删行后自己数对;两种列名、两种包装类都接) */
.jc-tbl-wrap table, .tbl-wrap table { counter-reset: rn; }
.jc-tbl-wrap table td.rn, .tbl-wrap table td.rn { counter-increment: rn; }
.jc-tbl-wrap table td.rn .jc-rn-num::before, .tbl-wrap table td.rn .jc-rn-num::before { content: counter(rn); }
.jc-tbl-wrap table td.num, .tbl-wrap table td.num { counter-increment: rn; }
.jc-tbl-wrap table td.num::before, .tbl-wrap table td.num::before { content: counter(rn); }
/* ── 看与改是两件事:只读份 / 输入孪生件由 .on 二选一 ── */
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.55;overflow-wrap:anywhere}
.jc-vcell.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.jc-vcell:empty::after{content:"—";color:var(--ink-4)}
.jc-cell{display:none;width:100%;min-width:72px;border:1px solid var(--line);border-radius:8px;padding:6px 9px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
.jc-cell:focus{border-color:var(--acc);box-shadow:0 0 0 3px var(--acc-soft);outline:none}
.jc-cell.mono{font-family:var(--mono);font-size:12px}
input.jc-cell, select.jc-cell { height: 32px; box-sizing: border-box; padding-top: 0; padding-bottom: 0; vertical-align: middle; }
table.on td>.jc-vcell{display:none}
table.on td>.jc-vcell.ro{display:block}
table.on td>.jc-cell{display:block}
table.on td>.jc-vcell:empty::after{content:none}
/* 增删行的痕(取消还原三件套的可视面) */
table.on tr.jc-row-new td { background: #f0fbf5; }
table.on tr.jc-row-gone td { background: #fff6f5; opacity: .55; }
table.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.jc-rowdel{display:none;cursor:pointer;border:1px solid var(--line);border-radius:999px;background:#fff;color:var(--ink-3);width:20px;height:20px;line-height:1;font-size:12px;padding:0;margin-left:5px;vertical-align:middle}
table.on .jc-rowdel{display:inline-grid;place-items:center}
.jc-rowdel:hover{border-color:var(--danger);color:var(--danger)}
table.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
/* 静态种子行:零行也加得出第一行 —— 只在编辑态露面(读态看见空壳子是界面撒谎) */
table:not(.on) tr.jc-row-static { display: none; }
table.on tr.jc-row-static td{background:var(--acc-wash);border-top:1px dashed var(--acc-line)}
/* 主键钥匙格:不给人看,但真挂 data-col(漏了钥匙,这一行凑不出主键) */
.rowkey{display:none}
/* ================= 徽标基座(取值上色一律 data-class-of 投影,选择器从值算) ================= */
.stx,.ct,.rl,.st,.risk-badge{display:inline-block;font-size:11px;font-weight:700;padding:1px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.rl{background:var(--soft);border-color:var(--line-soft);color:var(--ink-3);font-size:10.5px;letter-spacing:.03em}
.rec{display:inline-block;font-size:11.5px;font-weight:800;padding:2px 13px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.rec.rec-lg{font-size:14.5px;padding:7px 20px;letter-spacing:.03em}
/* kv 行(收口结论 / 待验项共用的键值版式) */
.kv{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--line-soft);font-size:12.5px}
.kv:last-child{border-bottom:0}
.kv i{flex:0 0 84px;font-style:normal;color:var(--ink-4);font-size:11px;font-weight:700;letter-spacing:.05em;padding-top:2px}
.kv > span{flex:1 1 auto;color:var(--ink-2);line-height:1.7;overflow-wrap:anywhere}
.kv > span:empty::after{content:"—";color:var(--ink-4)}
.kv-box{border:1px solid var(--line-soft);border-radius:12px;background:var(--soft);padding:4px 16px;margin-bottom:4px}
/* ================= 动画(全部 transform / opacity;reduced-motion 全静) ================= */
@keyframes sig{0%{transform:translateX(0);opacity:0}7%{opacity:1}93%{opacity:1}100%{transform:translateX(min(86vw,760px));opacity:0}}
@keyframes breathe{0%,100%{opacity:.55}50%{opacity:1}}
@keyframes sealspin{to{transform:rotate(360deg)}}
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.hero-top{animation:rise .5s cubic-bezier(.2,.7,.3,1) both}
.hero-title{animation:rise .5s .06s cubic-bezier(.2,.7,.3,1) both}
.stage,.stage-seal{animation:rise .55s .14s cubic-bezier(.2,.7,.3,1) both}
.hero-verdict,.hero-meta{animation:rise .55s .22s cubic-bezier(.2,.7,.3,1) both}
@media (prefers-reduced-motion: reduce){
*{animation:none !important;transition:none !important}
}
/* ================= 电路蓝图工作台(件① 的两张档案卡)=================
2026-08-31 用户:「卡片下面那个容器黑色和页面背景不搭」—— 深色板底退场,换**浅色蓝图**:
底是强调色的极浅晕染 + 淡格线,走线与信号点用强调色 —— 电路语汇留着,纸面色阶不断裂。 */
.board{position:relative;margin:0 0 6px;padding:18px 14px 16px;border-radius:16px;border:1px solid var(--acc-line);
background:
repeating-linear-gradient(0deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
repeating-linear-gradient(90deg, rgba(46,107,255,.045) 0 1px, transparent 1px 26px),
linear-gradient(158deg, var(--acc-wash) 0%, #fff 62%, var(--acc-wash) 100%)}
.board::before{content:"";position:absolute;left:4%;right:4%;top:50%;border-top:2px dashed var(--acc-line)}
/* 「可替换」动画(2026-08-31 用户第二稿:「不要做到背景板上,背景板会被卡片覆盖;
和卡片做到一级,可以压缩一点卡片的大小」)—— .dossiers 改三列网格,中间留一条
**实体动画道**(卡片各占左右列、相应收窄,谁也盖不住它):中央 ⇄ 徽轻呼吸,
两枚信号点绕徽**轨道对转**(此上彼下地绕圈 = 两颗料换位),底下横穿的虚线总线
在道里露出来把两张卡接起来。 */
.swapfx{position:absolute;left:50%;top:50%;width:76px;height:76px;transform:translate(-50%,-50%);z-index:1;pointer-events:none}
.sw-ic{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:36px;height:36px;padding:8px;box-sizing:border-box;color:var(--acc);background:#fff;border:1px solid var(--acc-line);border-radius:999px;box-shadow:0 4px 12px rgba(18,38,63,.16);animation:swbreathe 2.8s ease-in-out infinite}
.sw-orbit{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;animation:sworbit 4.5s linear infinite}
.sw-dot{position:absolute;left:50%;width:8px;height:8px;margin-left:-4px;border-radius:999px;background:var(--acc);box-shadow:0 0 8px 2px var(--acc-soft)}
.sw-d1{top:-4px}
.sw-d2{bottom:-4px}
@keyframes sworbit{to{transform:rotate(360deg)}}
@keyframes swbreathe{0%,100%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.1)}}
/* 三列网格:左右两列给卡,中间 76px 是动画道 —— 与卡同级的真实空间,不被覆盖 */
.dossiers{position:relative;z-index:1;display:grid;grid-template-columns:minmax(0,1fr) 76px minmax(0,1fr);row-gap:16px}
.dossiers > .dossier:nth-of-type(odd){grid-column:1}
.dossiers > .dossier:nth-of-type(even){grid-column:3}
.dossier{display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);transition:box-shadow .18s,transform .18s}
.dossier:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(18,38,63,.13)}
.dz-top{display:flex;align-items:center;gap:12px}
.dz-top .csvg{width:64px;flex:none}
.dz-id{display:flex;flex-direction:column;gap:4px;min-width:0}
.dz-role{display:flex;gap:6px;flex-wrap:wrap}
.dz-pn{font-size:15px;font-weight:800;overflow-wrap:anywhere;line-height:1.3}
.dz-pn .jc-vcell{font-size:15px;font-weight:800;color:var(--ink)}
.dz-type{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:8px 10px;border-radius:10px;background:var(--acc-wash);border:1px solid var(--acc-line)}
.dz-lb{flex:none;font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.05em;color:var(--ink-4)}
.dz-type .jc-cell{flex:1 1 140px;width:auto}
/* 类型名守卫:这一行的下拉真有选中项(selectCurrent 按行作用域写的 option[selected])才摆名 ——
行里没选,data-by 落回外层的名一个字都不摆,换「待选择」胶囊。
守卫挂在共同祖先 .dz-type 上、单层 :has —— :has() 里再嵌 :has() 是非法选择器,整条会被浏览器
静默丢弃(第一版就栽在这:守卫写了等于没写,XL 那张卡照样摆出它没有的类型)。 */
.tsel{flex:1 1 auto;min-width:0}
.tsel > span > i{display:inline-block;font-size:11.5px;font-weight:800;color:var(--acc-deep);background:var(--acc-soft);border-radius:999px;padding:1px 11px}
.dz-type:not(:has(option[selected])) .tsel > span{display:none}
.dz-type:not(:has(option[selected])) .tsel::after{content:"待选择 · 点「编辑」挑一个";display:inline-block;font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px}
.dz-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px 12px}
.fcell{display:flex;flex-direction:column;gap:3px;min-width:0}
.fcell.wide{grid-column:1 / -1}
.fcell > i{font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.05em;color:var(--ink-4)}
.fcell.req > i::after{content:" *";color:var(--danger)}
.fcell .jc-vcell{font-size:12.5px}
/* 三级占位:必填红 / 建议橘(空格子直说要不要填,不摆一枚含糊的破折号) */
.fcell.req .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
/* 徽标包装的必填格(如生命周期):没值时徽标 class 恰为裸 "stx"(class-of 没投出 v-*),
wrapper 不为 :empty,上一条够不着 —— 认裸 class 补同一枚胶囊(单层 :has) */
.fcell.req .jc-vcell:has(> [class="stx"])::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
.fcell.sug .jc-vcell:empty::after{content:"建议";font-size:10.5px;font-weight:800;color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:0 9px}
/* 档案卡的编辑态 —— **按卡开,只点亮这张卡的正面**(2026-08-31 用户:「各自编辑各自的部分,
不要做联动」:.on 由 scopedTargets 落在被点那张卡的行 section 上,不再落整个宿主);
只圈 .front 是因为卡背住着另一地址的宿主,不圈的话参数输入会被主数据的编辑顺手点亮 */
.dossier.on .front .jc-vcell{display:none}
.dossier.on .front .jc-vcell.ro{display:block}
.dossier.on .front .jc-cell{display:block}
.dossier.on .front .jc-vcell:empty::after{content:none}
.dossier.on .dz-role{display:flex}
/* ── 翻面(纯 CSS:checkbox + :has,零脚本;reduced-motion 下瞬时切换)── */
.dossier{position:relative;perspective:1200px;padding:0;background:none;border:0;box-shadow:none}
.dossier:hover{transform:none;box-shadow:none}
.flip-in{display:grid;transform-style:preserve-3d;transition:transform .55s cubic-bezier(.3,.7,.3,1)}
.face{grid-area:1 / 1;display:flex;flex-direction:column;gap:11px;background:#fff;border:1px solid var(--line-soft);border-radius:16px;padding:15px 16px 14px;box-shadow:0 8px 22px rgba(18,38,63,.09);backface-visibility:hidden;min-width:0}
.face.back{transform:rotateY(180deg)}
.flip-tg{position:absolute;opacity:0;pointer-events:none}
.flip-hit{position:absolute;inset:0;z-index:3;cursor:pointer}
.flip-cue{position:absolute;z-index:4;bottom:10px;right:10px;pointer-events:none;opacity:.6;transition:opacity .16s,transform .16s}
.flip-cue i{font-style:normal;display:inline-block;font-size:10.5px;font-weight:800;color:var(--acc-deep);background:var(--acc-soft);border:1px solid var(--acc-line);border-radius:999px;padding:2px 10px}
.dossier:hover .flip-cue{opacity:1;transform:translateY(-2px)}
/* 两句提示按面切换 —— 选择器都写到 .flip-cue i 之下的同层级,别被基座那条特异度压回来(踩过) */
.flip-cue .cue-back{display:none}
/* 翻转三态,后写的赢:人点的 < 主数据编辑锁正面 < 参数编辑翻背面 */
.dossier:has(.flip-tg:checked) .flip-in{transform:rotateY(180deg)}
.dossier:has(.flip-tg:checked) .flip-cue .cue-front{display:none}
.dossier:has(.flip-tg:checked) .flip-cue .cue-back{display:inline-block}
.dossier.on .flip-in{transform:rotateY(0deg)}
.dossier:has(.pback.on) .flip-in{transform:rotateY(180deg)}
/* 编辑中的**这张卡**不许翻(哪面在编辑锁哪面),点击层与提示丸一起收;另一张卡不受影响 */
.dossier.on .flip-hit,.dossier:has(.pback.on) .flip-hit{display:none}
.dossier.on .flip-cue,.dossier:has(.pback.on) .flip-cue{display:none}
/* 悬浮侧倾:预告「这张卡点了会翻」—— 只在读态、只在真有悬浮的设备、且不嫌动画的人那里 */
@media (hover:hover) and (prefers-reduced-motion: no-preference){
.dossier:hover:not(.on):not(:has(.flip-tg:checked)):not(:has(.pback.on)) .flip-in{transform:rotateY(-12deg)}
.dossier:hover:not(.on):has(.flip-tg:checked):not(:has(.pback.on)) .flip-in{transform:rotateY(168deg)}
}
/* ── 卡角编辑钮:笔=编辑当前这一面,软盘=保存,叉=放弃(2026-08-31 用户定,顶掉两行编辑闸)──
笔只在待处理态露面(html.editable,与从前的闸同一条纪律);软盘/叉只在对应面的编辑态露面。 */
.fabs{position:absolute;top:10px;right:10px;z-index:5;display:flex;gap:6px}
.fab{display:none;width:32px;height:32px;border-radius:999px;border:1px solid var(--acc-line);background:#fff;color:var(--acc-deep);cursor:pointer;place-items:center;padding:0;box-shadow:0 4px 12px rgba(18,38,63,.14);transition:border-color .16s,color .16s,box-shadow .16s}
.fab:hover{border-color:var(--acc);box-shadow:0 6px 16px rgba(18,38,63,.22)}
.fab:active{transform:scale(.94)}
.fic{width:15px;height:15px;display:block;margin:auto}
.fab.pen{color:var(--acc-deep)}
.fab.disk{background:var(--acc);border-color:var(--acc);color:#fff}
.fab.xbtn{color:var(--ink-3)}
/* 笔:这张卡当下这一面、且这张卡没在编辑 —— 各卡各开,互不联动 */
html.editable .dossier:not(.on):not(:has(.pback.on)):not(:has(.flip-tg:checked)) .pen-f{display:grid}
html.editable .dossier:not(.on):not(:has(.pback.on)):has(.flip-tg:checked) .pen-b{display:grid}
.dossier.on .disk-f{display:grid}
.dossier.on .x-f{display:grid}
.dossier:has(.pback.on) .disk-b{display:grid}
.dossier:has(.pback.on) .x-b{display:grid}
/* ── 卡背:参数瓷贴 ── */
.pb-hd{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap}
.pb-hd b{font-size:14px;font-weight:800;color:var(--ink)}
.pb-lb{font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--ink-4)}
.pback{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.ptile{display:flex;flex-direction:column;gap:2px;border:1px solid var(--line-soft);border-radius:10px;background:var(--soft);padding:8px 10px;min-width:0}
.pt-hd{display:flex;align-items:baseline;gap:6px;min-width:0}
.pt-hd .jc-vcell{font-size:11px;font-weight:800;color:var(--ink-3)}
.pt-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)}
.pt-val .jc-vcell{font-size:14px;font-weight:800;color:var(--ink)}
.pt-note .jc-vcell{font-size:10.5px;color:var(--ink-3)}
/* 三级占位:值必填红 / 说明建议橘 */
.ptile .pv .jc-vcell:empty::after{content:"必填";font-size:10.5px;font-weight:800;color:var(--danger);background:var(--danger-soft);border-radius:999px;padding:0 9px}
/* 说明空着**什么都不摆**(2026-08-31 用户:「没写就什么都不要展示」)—— 连兜底破折号一起收 */
.ptile .sg .jc-vcell:empty{display:none}
/* 参数编辑态:值与说明换输入,参数名照旧当标签摆着;两枚主键孪生件藏死(钥匙不是编辑面)。
藏钥匙用包装类(.pt-name / .rowkey),不用「data-col 属性选择器」—— 那段选择器文本会原样
出现在共享样式表里,把「落账确认全只读」那类字面断言踩翻(2026-08-31 踩过,连注释也不许写)。 */
.pback.on .pv > .jc-vcell,.pback.on .sg > .jc-vcell{display:none}
.pback.on .pv > .jc-cell,.pback.on .sg > .jc-cell{display:block}
.pback .pt-name .jc-cell{display:none}
.pt-name{display:inline-flex;min-width:0}
/* ================= 比对基准卡(类型 + 字段合一,只读、跟手)================= */
.basis{display:block;margin:0 0 10px}
.basis:empty{border:1.5px dashed var(--line);border-radius:12px;padding:16px;display:block}
.basis:empty::after{content:"还没定比对基准 —— 先在上面任一张档案卡上挑一个芯片类型,或展开下面的抽屉新建一个。";font-size:12.5px;color:var(--ink-3)}
.basis-in{display:flex;flex-direction:column;gap:10px;border:1px solid var(--acc-line);border-radius:12px;background:var(--acc-wash);padding:12px 14px 14px}
.basis-hd{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.basis-lb{flex:none;font-size:10.5px;font-weight:800;letter-spacing:.08em;color:var(--acc-deep)}
.basis-chip b{display:inline-block;font-size:13px;font-weight:800;color:#fff;background:var(--acc);border-radius:999px;padding:2px 14px;margin-right:8px}
.basis-desc{font-style:normal;font-size:11.5px;color:var(--ink-3)}
.basis-hd:has(.basis-chip:empty)::after{content:"待选择";font-size:11px;font-weight:700;color:var(--amber);background:var(--amber-soft);border:1px dashed #eeda9e;border-radius:999px;padding:1px 10px}
.basis-hd .csvg{width:56px;flex:none}
/* 类型图:层叠双封装 —— 后面那枚是「同类的别的芯片」,淡出成剪影 */
.basis-hd .tsvg{width:60px;flex:none}
.tsvg{display:block}
.t-ghost{opacity:.32}
.basis-id{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1 1 auto}
.basis-in:has(.ftiles:empty) .ftiles{display:none}
/* 降噪(2026-08-31 用户:「乱糟糟…没必要显示全就不要显示完全,鼠标指上去后再显示」):
字段收成一排丸(名 + 硬/软徽 + 单位),说明藏进悬浮气泡 —— 指上去才现身 */
.ftiles{display:flex;flex-wrap:wrap;gap:8px}
.ftile{position:relative;display:flex;flex-direction:row;align-items:center;gap:7px;border:1px solid var(--line-soft);border-radius:999px;background:#fff;padding:6px 13px;min-width:0;transition:border-color .16s,box-shadow .16s}
.ftile:hover{border-color:var(--acc-line);box-shadow:0 4px 12px rgba(18,38,63,.1)}
.ft-hd{display:flex;align-items:baseline;gap:7px;white-space:nowrap}
.ft-hd b{font-size:12px;font-weight:800;color:var(--ink)}
.ft-unit{font-style:normal;font-size:10px;color:var(--ink-4);font-family:var(--mono)}
.ft-desc{display:none;position:absolute;z-index:6;top:calc(100% + 7px);left:0;width:max-content;max-width:280px;margin:0;font-size:11px;line-height:1.65;color:var(--ink-2);background:#fff;border:1px solid var(--line);border-radius:9px;padding:8px 11px;box-shadow:0 10px 24px rgba(18,38,63,.16)}
.ftile:hover .ft-desc:not(:empty){display:block}
/* 类型说明:默认一行截断,悬浮题头才放开 */
.basis-desc{display:block;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.basis-hd:hover .basis-desc{white-space:normal}
/* ================= 新建类型工作抽屉(details,零脚本)================= */
.lab{margin:0 0 6px;border:1.5px dashed var(--line);border-radius:12px;background:var(--soft)}
.lab[open]{border-style:solid;background:#fff}
.lab-sm{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px;padding:11px 14px;font-size:12.5px;color:var(--ink-3);user-select:none}
.lab-sm::-webkit-details-marker{display:none}
.lab-sm b{color:var(--ink-2)}
.lab-sm:hover{color:var(--acc-deep)}
.lab-caret{margin-left:auto;flex:none;width:8px;height:8px;border-right:2px solid var(--ink-4);border-bottom:2px solid var(--ink-4);transform:rotate(45deg);transition:transform .16s}
.lab[open] .lab-caret{transform:rotate(-135deg)}
.lab-bd{padding:2px 14px 14px}
.lab-brief{margin:0 0 16px}
.lab-brief .brief{margin:2px 14px 14px;border-left-width:3px}
/* ================= 窄屏 ================= */
@media(max-width:620px){
.card-head{padding:16px 14px 18px}
.dz-grid{grid-template-columns:1fr}
.swapfx{display:none}
.dossiers{grid-template-columns:1fr}
.dossiers > .dossier:nth-of-type(odd),.dossiers > .dossier:nth-of-type(even){grid-column:1}
.pback{grid-template-columns:1fr}
.ftiles{grid-template-columns:1fr}
.card-bd{padding:16px 14px 6px}
.hero-title{font-size:19.5px}
.stage{gap:14px}
.hchip{min-width:108px;padding:10px 8px 9px}
.csvg{width:68px}
.kv i{flex-basis:72px}
.card-foot{padding:10px 14px}
}
`;
@@ -1,336 +0,0 @@
/**
* ACT-WORK-05 差异评估的四份目标件样式表 —— **机械抽取,一个数值都不许改**
* (《HTML报告生成规范》§7:有目标件时「搬」,不要「照着做」;样式表整段搬,标记逐块搬)。
*
* 来源(`docs/SS02-ACT-WORK-05-html/`):
* · `P08_CSS` ← `ACT-WORK-05-数据工单-01-芯片主数据创建.html` 的两段 `<style>`
* (该文件头部连续放了两个 `<style>` 元素:第一段是基座壳,第二段标着
* 「视觉补充段 v3」——`.hic`/`.zic`/`.thd`/`#tbl1`/`#tbl2` 等数据工单专属样式。
* 两段按文档原有顺序首尾相接存成一个常量;等价于浏览器解析两个 `<style>` 标签,
* 级联结果与原文件逐字节一致,已用 diff 验证,见 task-5-report.md);
* · `EXEC_CSS` ← `ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html` 的 `<style>`;
* · `ACC1_CSS` ← `ACT-WORK-05-验收工单-01-差异评估结论审核.html` 的 `<style>`;
* · `ACC2_CSS` ← `ACT-WORK-05-验收工单-02-评审放行与落账确认.html` 的 `<style>`。
*
* `p10-document-create`(P-10 原厂技术资料创建)**没有自己的目标件**——规格里数据工单是
* 「一个统一模板按目标台账实例化」,复用 `P08_CSS`(同一套壳,只是表头列换成 P-10 的字段)。
*
* 抽取用 `sed -n` 按行号截取 `<style>`/`</style>` 之间的原文,再拿抽出来的内容包回
* `<style>…</style>` 与源文件同一行区间做 `diff`,四份全部「零差异」——过程写在
* task-5-report.md,这里不重复贴证据。
*
* 这份文件只装**目标件自己的样式**,不装平台绑定用的开关(三态占位、缺项红带、
* `data-class-of` 的 `v-*` 别名这类)——那些是本活动自己加出来的东西,活在
* `report.ts` 的 `BIND_CSS_*` 常量里,两处不要混。
*/
export const P08_CSS = `
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--data:#3370ff; --data-soft:#e1eaff; --data-line:#c3d6ff;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--data);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(51,112,255,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0 0 6px;font-size:13px;color:var(--ink-2)}
.note p:last-child{margin-bottom:0}
.note b{color:var(--ink)}
.note .done{color:var(--ok);font-weight:700}
.note .need{color:var(--data);font-weight:700}
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
.jc-editgate-t{flex:1 1 auto;min-width:0}
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
.jc-editgate.on{background:var(--brand-wash);border-color:var(--data-line);color:#1e40af}
.jc-editgate.on .eg-open{display:none}
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
th.req::after{content:" *";color:var(--danger);font-size:11px}
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
table.on td>.jc-vcell{display:none}
table.on td>.jc-cell{display:block}
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
table.on td.rn .jc-rn-num{display:none}
table.on td.rn .jc-rowdel{display:block}
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.jc-addrow.show{display:flex;align-items:center;gap:8px}
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
.jc-addrow button:hover{background:var(--brand-soft)}
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
/* 补充:数据工单专用标识(仅追加,不改动基件样式) */
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.pre{font-style:normal;font-size:10.5px;font-weight:800;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:0 7px;margin-left:6px;white-space:nowrap}
table.on td>.pre{display:none}
.ct{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.ct.hard{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
.ct.soft{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
.tbl-gap{margin-top:18px}
/* ===== 视觉补充段 v3(通用批量模板:字段即表头、每行一条;不改基件样式与 JS 钩子) ===== */
.hic{flex:none;width:26px;height:26px;color:var(--data)}
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
/* 表头内字段说明(第二行小字) */
.thd{display:block;font-size:10px;font-weight:600;color:var(--ink-4);letter-spacing:0;margin-top:1px;line-height:1.4}
/* 表 1:字段即表头,内容多列,允许横向滚动 */
#tbl1{min-width:1240px}
#tbl1 .jc-vcell{display:inline}
/* 表 2 列宽 */
#tbl2 th:nth-child(2){width:150px}
#tbl2 th:nth-child(3){width:18%}
#tbl2 th:nth-child(4){width:14%}
#tbl2 th:nth-child(5){width:8%;text-align:center;white-space:nowrap}
#tbl2 th:nth-child(6){width:13%;text-align:center;white-space:nowrap}
#tbl2 td:nth-child(5),#tbl2 td:nth-child(6){text-align:center;white-space:nowrap}
/* 说明列(只读注释,编辑态保持可见) */
.desc{color:var(--ink-3);font-size:12px;line-height:1.5}
table.on td>.jc-vcell.ro{display:block}
.tbl-wrap{overflow-x:auto}
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
`;
export const EXEC_CSS = `
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.note .done{color:var(--ok);font-weight:700}
.note .need{color:var(--human);font-weight:700}
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
.jc-editgate-t{flex:1 1 auto;min-width:0}
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
.jc-egbtn.save{color:#fff;background:var(--human);border-color:var(--human)}
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
.jc-editgate.on{background:var(--human-soft);border-color:var(--human-line);color:#7c4a12}
.jc-editgate.on .eg-open{display:none}
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
th.req::after{content:" *";color:var(--danger);font-size:11px}
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
table.on td>.jc-vcell{display:none}
table.on td>.jc-cell{display:block}
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
table.on td.rn .jc-rn-num{display:none}
table.on td.rn .jc-rowdel{display:block}
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.jc-addrow.show{display:flex;align-items:center;gap:8px}
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
.jc-addrow button:hover{background:var(--brand-soft)}
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
/* 补充:评估输入清单状态标识(仅追加,不改动基件样式) */
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.st{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.st.bound{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#c9ecc2}
.st.miss{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
.st.def{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
tr.ro td{background:var(--soft)}
table.on td>.jc-vcell.ro{display:block}
`;
export const ACC1_CSS = `
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
.criteria li{margin:4px 0}
.criteria b{color:var(--ink)}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
.cust{font-weight:700}
.prio{font-family:var(--mono);font-weight:800}
.prio.hi{color:var(--danger)}
.prio.mid{color:var(--ink-2)}
.reason{font-size:12.5px;color:var(--ink-2)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
/* 补充:四态结论 pill 与风险等级(仅追加,不改动基件样式) */
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);white-space:nowrap;border:1px solid transparent}
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
.st.unknown{color:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
.risk{font-weight:800;font-size:12.5px}
.risk.low{color:var(--ok)}
.risk.mid{color:#8f6a12}
.risk.hi{color:var(--danger)}
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
`;
export const ACC2_CSS = `
:root{
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
--mono:"SF Mono",Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
.page{margin:0;padding:0}
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fdf3f1 0%,#fff 55%,#fbf2f0 100%)}
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--ledger);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(178,58,46,.25)}
.tit{flex:1;min-width:0}
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--ledger);background:var(--ledger-soft);border:1px solid var(--ledger-line)}
.step{font-size:12px;color:var(--ink-3)}
.deadline{font-size:12px;color:var(--ink-3)}
.deadline b{color:var(--danger)}
.card-bd{padding:20px 22px}
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
.zone b{color:var(--ink-3)}
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
.note p{margin:0;font-size:13px;color:var(--ink-2)}
.note b{color:var(--ink)}
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
.criteria li{margin:4px 0}
.criteria b{color:var(--ink)}
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.05em;font-weight:800;color:var(--ink-3);padding:9px 12px;border-bottom:1px solid var(--line)}
td{padding:11px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
.num{font-family:var(--mono);color:var(--ink-4);white-space:nowrap}
.cust{font-weight:700}
.prio{font-family:var(--mono);font-weight:800}
.prio.hi{color:var(--danger)}
.prio.mid{color:var(--ink-2)}
.reason{font-size:12.5px;color:var(--ink-2)}
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
.flow b{color:var(--ink)}
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
/* 补充:推荐等级 pill 与状态强调(仅追加,不改动基件样式) */
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.04em;font-family:var(--mono);white-space:nowrap;border:1px solid transparent}
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4}
.st.miss{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
.st.unknown{color:#8f6a12;background:#fdf3d7;border-color:#ecd9a0}
.st.cond{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
.risk{font-weight:800;font-size:12.5px}
.risk.mid{color:#8f6a12}
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
.tbl-gap{margin-top:18px}
`;
@@ -117,6 +117,15 @@ export type HumanToolService = {
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
chatCards(taskId: TaskId): Promise<ChatCard[]>;
/**
* **这一单正等人交回的卡**(2026-08-27):没办结的工单 + 没作答、没被顶替的对话卡。
* 引擎每拍开头问它 —— 有卡还没答,这一拍就不该问模型(叫了人就等人,不许自己往下推)。
*/
pendingCards(taskId: TaskId): Promise<Array<{
id: string;
title: string;
kind: 'work-order' | 'chat-card';
}>>;
/** org 停用前置检查要问的「待答卡」 */
openCardsOf(staffId: StaffId): Promise<Array<{
cardId: string;
+2
View File
@@ -26,6 +26,8 @@ export type CardStore = {
recipientId: StaffId;
} | undefined>;
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
/** 还开着的对话卡(没被新卡顶替、没作废)—— 「这一单有没有卡在等人」问的是它,不是全部历史 */
listOpenChatCards(taskId: TaskId): Promise<ChatCard[]>;
updateChatCard(card: ChatCard): Promise<void>;
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
/** 调人次数 —— 打扰率与预算的计数面 */
+1 -1
View File
@@ -7,6 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
+31 -3
View File
@@ -48,9 +48,29 @@ export type DraftWrite = {
readonly patch: LedgerRow;
readonly tombstone?: boolean;
};
/**
* **放行写的范围**(2026-08-27,数据工单确认即入库)。
*
* 不给 = 整单放行:草稿层全部并进正式表、清空。
* 给了 = 只并这几张表 —— 它们的门不是放行卡,是**数据工单的确认**(那张卡的提交闸
* 盯的就是这几张表:必填齐了才按得下确认,确认那一刻就入库)。并过的行**留在草稿层、
* 打上已落地记号**:它仍是本单写下的行(任务视图 / `mine:` / `_rows` 照旧认它),
* 只是整单放行时不再写它、不再为它抛事件。
* `cause` 落进数据事件的归因(`ledger-write:<cause>`),回看时认得出是哪张卡签下去的。
*/
export type ReleaseScope = {
readonly only: readonly ObjectAddress[];
readonly cause: string;
};
export type ReleaseWriteResult = {
readonly mergedRows: number;
readonly emittedEvents: number;
/**
* **早已落地、这次不再写的行数**(2026-08-27)—— 数据工单确认时并进去的那几行,
* 整单放行时只把它们当事实读回来(出口载荷要它们),不再写、不再抛事件。
* 放行痕上要分开说:「合并 N 行」里若混着这几行,那句话就多报了。
*/
readonly landedEarlierRows: number;
/**
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
@@ -100,7 +120,13 @@ export type LedgerService = {
address: ObjectAddress;
key: Record<string, unknown>;
}): Promise<boolean>;
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
/**
* 放行写。`scope` 见 `ReleaseScope`:不给 = 整单;给了 = 只并那几张表(数据工单确认那一道门),
* 并过的行留在草稿层打记号。**不是第四种写**,也不是绕门:同一条冲突检查 → 原子合并 → 抛事件的路,
* 只是门换成了那张卡的确认闸 —— 落哪几张表由发卡的人说了算(`gateLedgers`),调用方指不了别的表进来
* (清单没声明的表这一单本就写不进草稿层)。
*/
releaseWrite(taskId: TaskId, scope?: ReleaseScope): Promise<ReleaseWriteResult>;
/** 正式版查询 —— 跨任务只见过门的事实 */
query(query: LedgerQuery): Promise<LedgerPage>;
/** 任务视图 = 正式版 + 本任务草稿层 */
@@ -115,8 +141,10 @@ export type LedgerService = {
object: string;
key: Record<string, unknown>;
patch: Record<string, unknown>;
/** 已在数据工单确认那一道门上并进正式表(见 `ReleaseScope`);仍是本单写下的行 */
landed: boolean;
}>>;
/** 落地预检第五项:数据冲突检查 */
checkConflicts(taskId: TaskId): Promise<ConflictFinding[]>;
/** 落地预检第五项:数据冲突检查;`only` 给了就只查那几张表(与 `releaseWrite` 的 scope 同一口径) */
checkConflicts(taskId: TaskId, only?: readonly ObjectAddress[]): Promise<ConflictFinding[]>;
discardDrafts(taskId: TaskId): Promise<void>;
};
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* module: ledger —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type { ConflictFinding, DraftWrite, LedgerAddress, LedgerKey, LedgerPage, LedgerQuery, LedgerRow, LedgerService, MaterializedObject, MergeConflict, MergeNotice, MergedObject, ObjectAddress, ObjectDeclaration, ReleaseWriteResult, SchemaApplyResult, SystemWrite, WriteKind, } from './contract.ts';
export type { ConflictFinding, DraftWrite, LedgerAddress, LedgerKey, LedgerPage, LedgerQuery, LedgerRow, LedgerService, MaterializedObject, MergeConflict, MergeNotice, MergedObject, ObjectAddress, ObjectDeclaration, ReleaseScope, ReleaseWriteResult, SchemaApplyResult, SystemWrite, WriteKind, } from './contract.ts';
export { DraftConflictError, ObjectNotMaterializedError, SchemaConflictError } from './contract.ts';
export type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from './port.ts';
export type { LedgerServiceDeps } from './internal/usecase/ledger-service.ts';
+9
View File
@@ -77,5 +77,14 @@ export type DraftStore = {
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
*/
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
/**
* 给本单草稿层的一行打上「已落地」记号(2026-08-27,数据工单确认即入库)。
*
* 那一行刚经一道更早的门并进了正式表:条目**留着**(它仍是本单写下的行,
* 任务视图 / `mine:` / `_rows` 照旧认它),只是放行时不再写它。
* `baseline` 换成落地后正式表里的值 —— 之后再往这一行写一笔(记号随之擦掉),
* 冲突检查比的才是「我改之前表里是什么」,而不是落地前那份早已过时的基线。
*/
markLanded(taskId: string, ref: ObjectRef, key: LedgerKey, baseline: LedgerRow): Promise<void>;
clear(taskId: string): Promise<void>;
};
+1 -1
View File
@@ -3,7 +3,7 @@
*/
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
export type { AgentPort, AnalyzeGateway, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
+77
View File
@@ -38,6 +38,12 @@ export type TaskDataGateway = {
object: string;
label: string;
keys: string[];
/**
* 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段**
* (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看,
* 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。
*/
caseKey?: string | undefined;
fields: Array<{
name: string;
label: string;
@@ -56,8 +62,23 @@ export type TaskDataGateway = {
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
/**
* **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍,
* 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。
* 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**,
* 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。
* 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。
*/
skipUnchanged?: boolean | undefined;
/**
* **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。
* 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义,
* 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。
*/
skipExisting?: boolean | undefined;
}): Promise<{
changed: boolean;
skipped?: boolean;
}>;
/**
* **撤掉本单草稿层的一行** —— 写错了得有退路。
@@ -71,8 +92,21 @@ export type TaskDataGateway = {
domain: string;
object: string;
key: Record<string, string | number>;
/**
* **已落地的行:跳过,不拒**(2026-08-31,配方专用 —— 与 `draftWrite` 的两个旗同族)。
* 「数据工单确认即入库」把行落进正式表后,草稿层那一笔只是「本单写过它」的记号;
* 撤记号正式表一个字不动,读面反而少一行 —— 所以对人/模型那一路它是硬拒(拒回是
* 行动指南)。而配方 `prune` 的语义是「陈旧的投影行清掉」:已落地 = 已经是世界的
* 一部分,不是陈旧草稿 —— 对配方来说跳过是常态,抛错就是整单停摆(2026-08-30
* e2e 案例 2 的死锁:人确认入库后改了类型选择,prune 想撤已落地的旧类型行,当场炸;
* 当时以「②③ 不带 prune」绕开,代价是**没落地的**陈旧行也跟着不清了 ——
* 2026-08-31 用户实测点名的「改回同一类型,下面还是两个类型」正是这半截代价)。
* 模型那一路不传这个旗:行为一个字不变。
*/
skipLanded?: boolean | undefined;
}): Promise<{
dropped: boolean;
landed?: boolean;
}>;
};
/**
@@ -207,6 +241,8 @@ export type CardGateway = {
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
itemId?: string | undefined;
card: Record<string, unknown>;
}): Promise<{
id: string;
@@ -258,9 +294,28 @@ export type CardGateway = {
v: string;
}>;
allowCustom?: boolean;
/** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */
itemId?: string | undefined;
/**
* **这张卡穿哪副面孔,引擎已经按剧本定好**(2026-08-27):模型没给 itemId、或给的项挂不上时,
* 组装层按 itemId 查剧本就查不到,卡就退成「需要你定」—— 而这一步的剧本明明写着 `cardVariant: 'review'`。
* 引擎知道当下在办哪一步,由它把面孔定下来递过去;组装层只在这里没给时才自己按 itemId 查。
*/
variant?: 'review' | undefined;
/** 人工验收清单(剧本 `acceptanceChecklist`)—— 审核卡上一条一枚「达标 / 不达标」开关 */
checklist?: ReadonlyArray<string> | undefined;
}): Promise<{
id: string;
}>;
/**
* **这一单还有哪几张卡在等人交回**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
* 每拍开头问一次:有卡没答,这一拍不问模型 —— 叫了人就等人;
* 任何多余的唤醒(迟到的回传事件、另一台后端的推进器)都推不动它。
*/
unanswered(taskId: TaskId): Promise<ReadonlyArray<{
id: string;
title: string;
}>>;
};
export type ListenerRegistrar = {
register(input: {
@@ -368,3 +423,25 @@ export type TaskContext = {
*/
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
};
/**
* **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。
*
* 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账;
* 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。
* **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。
*/
export type AnalyzeGateway = {
run(input: {
readonly taskId: TaskId;
/** 活动声明过的模型名 */
readonly model: string;
readonly instruction: string;
/** 系统组装好的入参 —— 一行一条 */
readonly payload: readonly unknown[];
readonly outputSchema: Readonly<Record<string, unknown>>;
/** 归因:`<步名>#<第几笔>` */
readonly label: string;
}): Promise<{
readonly object: Readonly<Record<string, unknown>>;
}>;
};
+4
View File
@@ -26,6 +26,10 @@ export type SubmitForReleaseInput = {
}>;
readonly deliverables: readonly DeliverableCheck[];
readonly criteria: readonly CriterionCheck[];
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
readonly checklist?: ReadonlyArray<string>;
/** 这张卡打回得了吗 —— `false` = 打回之后没人接得住(见契约 `AcceptanceWorkOrder.redoable`) */
readonly redoable?: boolean;
/** 报告可修订:AI 拟值已填的表单字段 */
readonly fields?: ReadonlyArray<Record<string, unknown>>;
/**
+6
View File
@@ -25,6 +25,8 @@ export type LedgerGateway = {
emittedEvents: number;
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
simulatedRows: number;
/** 早已在数据工单确认时入库、这次不再写的行数 —— 放行痕上分开说,「合并 N 行」不多报 */
landedEarlierRows: number;
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
merged: ReadonlyArray<{
domain: string;
@@ -89,6 +91,10 @@ export type CardGateway = {
effect: 'read' | 'write';
detail: string;
}>;
/** 人工验收清单 —— 原样带上卡 */
checklist?: ReadonlyArray<string> | undefined;
/** 这张卡打回得了吗(`false` = 那颗钮不摆)—— 端口不声明它,值就在装配层被丢掉,这行不能省 */
redoable?: boolean | undefined;
/**
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
+5
View File
@@ -77,10 +77,15 @@ export type TaskService = {
/** 推进一拍:**预算在环外先扣先判**,再交范式引擎 */
tick(taskId: TaskId): Promise<TickOutcome>;
/** 事件驱动续跑 —— 挂起期间事件不丢,恢复即补投 */
/**
* 投递一条事件并推一拍。`occurredAt` = 事件发生时刻(信封的 occurredAt):人事件要拿它与任务的
* 最近推进时刻比 —— 回传那一刻已经 wake、推进器抢先跑了那一拍的,这条事件只是迟到的回声,不再推。
*/
deliver(taskId: TaskId, event: {
kind: 'data' | 'time' | 'human' | 'control';
ref: string;
payload: Record<string, unknown>;
occurredAt?: string;
}): Promise<TickOutcome>;
/** 控制三语义:暂停 / 叫停 / 终止 */
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export {};
+376
View File
@@ -0,0 +1,376 @@
/**
* 运营中心 · 活动模块的契约(L1 活动清单 / L2 活动详情 / L3 实例详情;`docs/API文档-活动模块.md`)。
*
* 只有 zod schema 与推导类型。两个概念(2026-08-25 用户定):
* · **活动** = 装进来的活动包在运营面上的投影 —— 名称 / 分类 / 版本 / 模型来自清单,
* 负责人 / 说明 / 图标 / 色相来自运营侧档案(`sys.activity_profile`,活动包本身不载这些);
* · **实例** = 这个活动的一次运行(一张 task)—— 五态是对 task 运行态 / 终态的**如实映射**,
* 不是第六套状态机(见 `INSTANCE_STATE_LABEL` 旁的口径)。
*/
import { z } from 'zod';
/** 运营态:激活中 / 已禁用 —— 禁用只拦「新启动」,已完成与在途实例都不受影响(2026-08-27 用户定) */
export declare const ACTIVITY_STATUSES: readonly ["active", "disabled"];
export declare const activityStatusSchema: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
export type ActivityStatus = z.infer<typeof activityStatusSchema>;
export declare const ACTIVITY_STATUS_LABEL: Readonly<Record<ActivityStatus, string>>;
/** 业务色相 —— 一卡一色;缺省由前端兜底(`blue`) */
export declare const ACTIVITY_HUES: readonly ["blue", "teal", "amber", "violet"];
export declare const activityHueSchema: z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>;
export type ActivityHue = z.infer<typeof activityHueSchema>;
/** 凡出人都是两段:展示用 name,筛选 / 提交用 id */
export declare const staffRefSchema: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
export type StaffRef = z.infer<typeof staffRefSchema>;
export declare const activitySummarySchema: z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
biz: z.ZodString;
status: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
version: z.ZodString;
owner: z.ZodNullable<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
instanceCount: z.ZodNumber;
createdAt: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
}, z.core.$strip>;
export type ActivitySummary = z.infer<typeof activitySummarySchema>;
export declare const activityDetailSchema: z.ZodObject<{
name: z.ZodString;
id: z.ZodString;
version: z.ZodString;
status: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
createdAt: z.ZodString;
biz: z.ZodString;
owner: z.ZodNullable<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
export type ActivityDetail = z.infer<typeof activityDetailSchema>;
/**
* 沙箱视图向父页报高的消息类型(postMessage 载荷 `{ type, height }`)。
* 视图模版本身**无脚本**(安装静态审拦着);报高脚本由服务端在出口那一刻注入。
*/
export declare const ACTIVITY_VIEW_HEIGHT_MESSAGE = "jcai:view-height";
/**
* 实例五态(2026-08-25 用户定),对 task 的如实映射:
* pending 已建单、推进器还没领到(`used.steps = 0`)
* running 在途(含等人回传 / 监听中 / 挂起 / 落地受阻)
* completed 终态 released
* rejected 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 running)
* failed 终态 blocked / stalled / exhausted,以及手动终止(靠 `reason.kind='terminated'` 区分)
*/
export declare const INSTANCE_STATES: readonly ["pending", "running", "completed", "rejected", "failed"];
export declare const instanceStateSchema: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
export type InstanceState = z.infer<typeof instanceStateSchema>;
export declare const INSTANCE_STATE_LABEL: Readonly<Record<InstanceState, string>>;
export declare const instanceSummarySchema: z.ZodObject<{
id: z.ZodString;
no: z.ZodString;
state: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
owner: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
input: z.ZodString;
output: z.ZodNullable<z.ZodString>;
startedAt: z.ZodString;
}, z.core.$strip>;
export type InstanceSummary = z.infer<typeof instanceSummarySchema>;
export declare const instanceStatsSchema: z.ZodObject<{
total: z.ZodNumber;
byState: z.ZodRecord<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>, z.ZodNumber>;
}, z.core.$strip>;
export type InstanceStats = z.infer<typeof instanceStatsSchema>;
export declare const instanceListSchema: z.ZodObject<{
stats: z.ZodObject<{
total: z.ZodNumber;
byState: z.ZodRecord<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>, z.ZodNumber>;
}, z.core.$strip>;
total: z.ZodNumber;
page: z.ZodNumber;
size: z.ZodNumber;
items: z.ZodArray<z.ZodObject<{
id: z.ZodString;
no: z.ZodString;
state: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
owner: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
input: z.ZodString;
output: z.ZodNullable<z.ZodString>;
startedAt: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
export type InstanceList = z.infer<typeof instanceListSchema>;
export declare const instanceFileSchema: z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>;
export type InstanceFile = z.infer<typeof instanceFileSchema>;
export declare const INSTANCE_REASON_KINDS: readonly ["rejected", "failed", "terminated"];
export declare const instanceReasonSchema: z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
text: z.ZodString;
}, z.core.$strip>;
export type InstanceReason = z.infer<typeof instanceReasonSchema>;
export declare const STEP_RESULTS: readonly ["done", "fail", "current", "pending"];
export declare const instanceStepSchema: z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
res: z.ZodEnum<{
pending: "pending";
done: "done";
fail: "fail";
current: "current";
}>;
note: z.ZodString;
}, z.core.$strip>;
export type InstanceStep = z.infer<typeof instanceStepSchema>;
/** 起单来源:manual(运营端手动启动)/ upstream(工作流节点)/ mail 等通道名 / testbed(测试台试跑)/ system */
export declare const instanceSourceSchema: z.ZodObject<{
kind: z.ZodString;
label: z.ZodString;
}, z.core.$strip>;
export type InstanceSource = z.infer<typeof instanceSourceSchema>;
export declare const instanceDetailSchema: z.ZodObject<{
id: z.ZodString;
state: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
startedAt: z.ZodString;
owner: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
text: z.ZodString;
}, z.core.$strip>>;
input: z.ZodObject<{
content: z.ZodString;
source: z.ZodObject<{
kind: z.ZodString;
label: z.ZodString;
}, z.core.$strip>;
submittedAt: z.ZodString;
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
res: z.ZodEnum<{
pending: "pending";
done: "done";
fail: "fail";
current: "current";
}>;
note: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
export type InstanceDetail = z.infer<typeof instanceDetailSchema>;
/**
* 控件种类:text 单行 / textarea 多行 / select 单选(只能选候选)/ combo 选择 + 新增 / list 字符串数组。
* 新增种类前后端一起升级;前端遇到不认识的 type 摆「暂不支持的控件」。
*/
export declare const LAUNCH_PARAM_TYPES: readonly ["text", "textarea", "select", "combo", "list"];
export declare const launchParamTypeSchema: z.ZodEnum<{
text: "text";
textarea: "textarea";
select: "select";
combo: "combo";
list: "list";
}>;
export type LaunchParamType = z.infer<typeof launchParamTypeSchema>;
/**
* 一条候选。`group` 是**联动分组键**(2026-08-31 用户定):清单 `x-options.group` 指了哪一列,
* 这颗候选就带上它在那一列的值(如料号的 `chip_type_id`);空串 = 那一列没值(「未标类型」)。
* `groupLabel` 是这枚键的人读名(从字典表现读,如「DC-DC 降压稳压器」),读不到就没有 —— 界面退回摆键本身。
*/
export declare const launchOptionSchema: z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
group: z.ZodOptional<z.ZodString>;
groupLabel: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type LaunchOption = z.infer<typeof launchOptionSchema>;
export declare const launchParamSchema: z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
type: z.ZodEnum<{
text: "text";
textarea: "textarea";
select: "select";
combo: "combo";
list: "list";
}>;
required: z.ZodOptional<z.ZodBoolean>;
desc: z.ZodOptional<z.ZodString>;
placeholder: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
group: z.ZodOptional<z.ZodString>;
groupLabel: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
linkGroup: z.ZodOptional<z.ZodString>;
distinctGroup: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type LaunchParam = z.infer<typeof launchParamSchema>;
/**
* 「算不算同一个」的归一:去空白 + 不分大小写 —— **前后端同一把尺**。
* 大小写要归:料号这类身份不因字母大小写而不同,只挡精确相等的话,`lm2596s-5.0` 会照样进去,
* 混一颗垃圾主数据出来,人还得到一份自己跟自己比的评估。
*/
export declare function launchDistinctKey(value: string): string;
/** 撞上了那一格的话 —— 前端(填的当口)与服务端(400 的 detail)同一句,不许分叉 */
export declare function launchDistinctMessage(otherLabel: string): string;
export declare const launchParamsSchema: z.ZodObject<{
activityId: z.ZodString;
version: z.ZodString;
params: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
type: z.ZodEnum<{
text: "text";
textarea: "textarea";
select: "select";
combo: "combo";
list: "list";
}>;
required: z.ZodOptional<z.ZodBoolean>;
desc: z.ZodOptional<z.ZodString>;
placeholder: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
group: z.ZodOptional<z.ZodString>;
groupLabel: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
linkGroup: z.ZodOptional<z.ZodString>;
distinctGroup: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type LaunchParams = z.infer<typeof launchParamsSchema>;
export declare const launchInputSchema: z.ZodObject<{
idempotencyKey: z.ZodString;
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
}, z.core.$strip>;
export type LaunchInput = z.infer<typeof launchInputSchema>;
+526
View File
@@ -0,0 +1,526 @@
/**
* 运营中心 · 活动模块的路由契约(`docs/API文档-活动模块.md`)。
*
* 全部 `access: 'ops'` —— 活动区落在运营中心,与工作流 / 任务看板同端。
* `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。
* 设计端的活动库(包版本 / 资产版本 / 装入)在 `/api/gov/activity-packages`(governance.ts),
* `/api/gov/activities/*` 整个归运营端(2026-08-27 用户定)。
*/
import { z } from 'zod';
import type { RouteContract } from './http.ts';
export declare const listOpsActivitiesRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities";
readonly access: "ops";
/** 不带 status = 全量;量小不分页 */
readonly query: z.ZodObject<{
status: z.ZodOptional<z.ZodEnum<{
active: "active";
disabled: "disabled";
}>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
activities: z.ZodArray<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
biz: z.ZodString;
status: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
version: z.ZodString;
owner: z.ZodNullable<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
instanceCount: z.ZodNumber;
createdAt: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
export declare const getOpsActivityRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities/:activityId";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
name: z.ZodString;
id: z.ZodString;
version: z.ZodString;
status: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
createdAt: z.ZodString;
biz: z.ZodString;
owner: z.ZodNullable<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/**
* 沙箱要渲染的活动视图 —— 处理器回 `text/html`(不是 JSON),`output` 只是形式上的字符串。
* iframe 带不了 Authorization 头,走票据 `?ticket=`(GET,中间件已认)。404 = 该活动没配视图。
*/
export declare const opsActivityViewRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities/:activityId/view";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodString;
};
export declare const listOpsInstancesRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities/:activityId/instances";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
readonly query: z.ZodObject<{
id: z.ZodOptional<z.ZodString>;
ownerId: z.ZodOptional<z.ZodString>;
state: z.ZodOptional<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>>;
startedFrom: z.ZodOptional<z.ZodString>;
startedTo: z.ZodOptional<z.ZodString>;
page: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
size: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
stats: z.ZodObject<{
total: z.ZodNumber;
byState: z.ZodRecord<z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>, z.ZodNumber>;
}, z.core.$strip>;
total: z.ZodNumber;
page: z.ZodNumber;
size: z.ZodNumber;
items: z.ZodArray<z.ZodObject<{
id: z.ZodString;
no: z.ZodString;
state: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
owner: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
input: z.ZodString;
output: z.ZodNullable<z.ZodString>;
startedAt: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
export declare const opsLaunchParamsRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities/:activityId/launch-params";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
activityId: z.ZodString;
version: z.ZodString;
params: z.ZodArray<z.ZodObject<{
key: z.ZodString;
label: z.ZodString;
type: z.ZodEnum<{
text: "text";
textarea: "textarea";
select: "select";
combo: "combo";
list: "list";
}>;
required: z.ZodOptional<z.ZodBoolean>;
desc: z.ZodOptional<z.ZodString>;
placeholder: z.ZodOptional<z.ZodString>;
options: z.ZodOptional<z.ZodArray<z.ZodObject<{
value: z.ZodString;
label: z.ZodString;
group: z.ZodOptional<z.ZodString>;
groupLabel: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
linkGroup: z.ZodOptional<z.ZodString>;
distinctGroup: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/**
* 启动活动 = 创建实例。同一 `idempotencyKey` 重放返回第一次创建的实例;
* 412 活动已禁用;400 缺必填 / 类型不符 / select 值不在候选 / 责任人不在职;409 同键正在创建中。
*/
export declare const launchOpsInstanceRoute: {
readonly method: "POST";
readonly path: "/api/gov/activities/:activityId/instances";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
readonly body: z.ZodObject<{
idempotencyKey: z.ZodString;
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
params: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.ZodString;
state: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
startedAt: z.ZodString;
owner: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
text: z.ZodString;
}, z.core.$strip>>;
input: z.ZodObject<{
content: z.ZodString;
source: z.ZodObject<{
kind: z.ZodString;
label: z.ZodString;
}, z.core.$strip>;
submittedAt: z.ZodString;
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
res: z.ZodEnum<{
pending: "pending";
done: "done";
fail: "fail";
current: "current";
}>;
note: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
export declare const getOpsInstanceRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities/:activityId/instances/:instanceId";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
instanceId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.ZodString;
state: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
startedAt: z.ZodString;
owner: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
text: z.ZodString;
}, z.core.$strip>>;
input: z.ZodObject<{
content: z.ZodString;
source: z.ZodObject<{
kind: z.ZodString;
label: z.ZodString;
}, z.core.$strip>;
submittedAt: z.ZodString;
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
res: z.ZodEnum<{
pending: "pending";
done: "done";
fail: "fail";
current: "current";
}>;
note: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/**
* 实例文件:默认 JSON + base64(与 `GET /api/gov/upload/:id` 同形);`?raw=1` 直出字节
* (RFC 5987 文件名),配 `?ticket=` 给 `<a href download>` 用。
*/
export declare const opsInstanceFileRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
instanceId: z.ZodString;
io: z.ZodEnum<{
output: "output";
input: "input";
}>;
index: z.ZodCoercedNumber<unknown>;
}, z.core.$strip>;
readonly query: z.ZodObject<{
raw: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
name: z.ZodString;
contentType: z.ZodString;
dataBase64: z.ZodString;
}, z.core.$strip>;
};
/** 任何时刻都可禁用:只拦新启动,在途实例继续跑完;已禁用再禁用 = 幂等 200 */
export declare const disableOpsActivityRoute: {
readonly method: "POST";
readonly path: "/api/gov/activities/:activityId/disable";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
name: z.ZodString;
id: z.ZodString;
version: z.ZodString;
status: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
createdAt: z.ZodString;
biz: z.ZodString;
owner: z.ZodNullable<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
export declare const activateOpsActivityRoute: {
readonly method: "POST";
readonly path: "/api/gov/activities/:activityId/activate";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
name: z.ZodString;
id: z.ZodString;
version: z.ZodString;
status: z.ZodEnum<{
active: "active";
disabled: "disabled";
}>;
createdAt: z.ZodString;
biz: z.ZodString;
owner: z.ZodNullable<z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>>;
llm: z.ZodString;
ico: z.ZodOptional<z.ZodString>;
hue: z.ZodOptional<z.ZodEnum<{
blue: "blue";
teal: "teal";
amber: "amber";
violet: "violet";
}>>;
desc: z.ZodString;
view: z.ZodNullable<z.ZodObject<{
url: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** body 空;→ `state='failed'` + `reason.kind='terminated'`;412 实例已终态 */
export declare const terminateOpsInstanceRoute: {
readonly method: "POST";
readonly path: "/api/gov/activities/:activityId/instances/:instanceId/terminate";
readonly access: "ops";
readonly params: z.ZodObject<{
activityId: z.ZodString;
instanceId: z.ZodString;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.ZodString;
state: z.ZodEnum<{
running: "running";
completed: "completed";
pending: "pending";
failed: "failed";
rejected: "rejected";
}>;
startedAt: z.ZodString;
owner: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
name: z.ZodString;
}, z.core.$strip>;
no: z.ZodString;
activityId: z.ZodString;
llm: z.ZodString;
endedAt: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
reason: z.ZodNullable<z.ZodObject<{
kind: z.ZodEnum<{
terminated: "terminated";
failed: "failed";
rejected: "rejected";
}>;
text: z.ZodString;
}, z.core.$strip>>;
input: z.ZodObject<{
content: z.ZodString;
source: z.ZodObject<{
kind: z.ZodString;
label: z.ZodString;
}, z.core.$strip>;
submittedAt: z.ZodString;
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
output: z.ZodObject<{
files: z.ZodArray<z.ZodObject<{
index: z.ZodNumber;
name: z.ZodString;
contentType: z.ZodString;
size: z.ZodNumber;
}, z.core.$strip>>;
}, z.core.$strip>;
steps: z.ZodArray<z.ZodObject<{
seq: z.ZodNumber;
name: z.ZodString;
by: z.ZodEnum<{
human: "human";
agent: "agent";
}>;
byName: z.ZodString;
at: z.ZodNullable<z.ZodString>;
durationMs: z.ZodNullable<z.ZodNumber>;
res: z.ZodEnum<{
pending: "pending";
done: "done";
fail: "fail";
current: "current";
}>;
note: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
};
/** 全部 11 条 —— 文档总表与覆盖测试按它数 */
export declare const ACTIVITY_OPS_ROUTES: readonly RouteContract[];
+44 -2
View File
@@ -1993,10 +1993,12 @@ export declare const saveWorkGroupRoute: {
* 安装两审在服务端跑:静态审读清单(工具契约在否、模型名绑没绑、模版有没有脚本、
* 声明与已物化的台账冲不冲),回放审重演历史(首装无历史即免,以样例集替代)。
* **恒为新增、版本并存**:装入不覆盖旧版本,在途任务按其启动时版本跑完。
* **路径(2026-08-27)**:`/api/gov/activity-packages` —— 从前叫 `/api/gov/activities`;那个前缀整个让给了运营端
* 活动模块(L1/L2/L3,`api/activity-ops.ts`),这里管的是**包**(版本 / 资产 / 装入),名副其实。
*/
export declare const installActivityRoute: {
readonly method: "POST";
readonly path: "/api/gov/activities";
readonly path: "/api/gov/activity-packages";
readonly access: "design";
readonly body: z.ZodObject<{
manifest: z.ZodObject<{
@@ -2115,6 +2117,7 @@ export declare const installActivityRoute: {
derived: "derived";
}>>;
caseKey: z.ZodOptional<z.ZodString>;
createOnly: z.ZodOptional<z.ZodBoolean>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
@@ -2229,6 +2232,13 @@ export declare const installActivityRoute: {
view: z.ZodOptional<z.ZodString>;
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
doneWhen: z.ZodOptional<z.ZodString>;
cardVariant: z.ZodOptional<z.ZodEnum<{
data: "data";
review: "review";
}>>;
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
auto: z.ZodOptional<z.ZodType<import("../auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("../auto-recipe.ts").AutoRecipe, unknown>>>;
}, z.core.$strip>>>;
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
@@ -2250,6 +2260,32 @@ export declare const installActivityRoute: {
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
html: z.ZodString;
data: z.ZodOptional<z.ZodObject<{
case: z.ZodObject<{
ledger: z.ZodString;
key: z.ZodString;
prefix: z.ZodString;
}, z.core.$strip>;
rows: z.ZodArray<z.ZodObject<{
as: z.ZodString;
ledger: z.ZodString;
by: z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>;
not: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>>;
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
field: z.ZodString;
dir: z.ZodEnum<{
desc: "desc";
asc: "asc";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -2348,9 +2384,10 @@ export declare const registerToolRoute: {
}, z.core.$strip>;
}, z.core.$strip>;
};
/** 路径同 `installActivityRoute` 的说明:`/api/gov/activity-packages`(2026-08-27 让位给运营端活动模块) */
export declare const listActivitiesRoute: {
readonly method: "GET";
readonly path: "/api/gov/activities";
readonly path: "/api/gov/activity-packages";
readonly access: "design";
readonly output: z.ZodObject<{
activities: z.ZodArray<z.ZodObject<{
@@ -2798,6 +2835,11 @@ export declare const testActivityStateRoute: {
execution: "execution";
acceptance: "acceptance";
}>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
redoable: z.ZodOptional<z.ZodBoolean>;
title: z.ZodString;
done: z.ZodBoolean;
result: z.ZodOptional<z.ZodString>;
+1
View File
@@ -4,3 +4,4 @@ export * from './governance.ts';
export * from './org-directory.ts';
export * from './ingest.ts';
export * from './mail.ts';
export * from './activity-ops.ts';
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
/**
* 确定性步骤的**声明式配方**(ADR-014 / spec 2026-08-29 §5.1)。
*
* 配方是**数据不是代码**:装进来仍然只是数据,可静态审、可回放、可在治理台上摆出
* 「这一步系统会做什么」。执行第三方代码则相反 —— ADR-004 的立场是「不执行包代码即知包为何物」。
*
* **只声明真实现了的那几式。** 表达力有上限是刻意的:上限之外的不要硬塞,
* 退回模型,或者把那一段判断从这一步里切出去。
*/
import { z } from 'zod';
/** 表达式 —— 递归,`z.lazy` 是唯一写法(zod 的自引用) */
export type AutoExpr = string | number | boolean
/** 入参 / 基座读数 / `let` 里算好的值 —— 三者同一个命名空间,`let` 优先 */
| {
readonly in: string;
}
/** 本行的列 */
| {
readonly col: string;
}
/** 外层那一行的列(两层嵌套时) */
| {
readonly outer: string;
}
/** 拼接,一律按字符串 */
| {
readonly cat: readonly AutoExpr[];
}
/** 只留数字(铸号:`case:openedAt` → `20260829215103`) */
| {
readonly fmt: AutoExpr;
readonly as: 'digits';
}
/** 查另一张表的一列;查不到给 `else`(缺省空串) */
| {
readonly lookup: string;
readonly by: Readonly<Record<string, AutoExpr>>;
readonly take: string;
readonly else?: AutoExpr | undefined;
}
/** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */
| {
readonly when: string;
readonly then: AutoExpr;
readonly else: AutoExpr;
};
export declare const autoExprSchema: z.ZodType<AutoExpr>;
/**
* 数据源 —— 这一笔要对着几行办。
*
* 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)·
* `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
* **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。
*/
export type AutoSource = {
readonly once: true;
} | {
readonly rows: readonly Readonly<Record<string, AutoExpr>>[];
} | {
readonly each: string;
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
/** 第二层,**到此为止** —— 三层以上就别 auto 了 */
readonly then?: {
readonly each: string;
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
} | undefined;
};
export declare const autoSourceSchema: z.ZodType<AutoSource>;
/** 五个动词 —— `when` 为假就跳过这一笔 */
export type AutoAction = {
readonly kind: 'project';
readonly when?: string | undefined;
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
readonly into: string;
/**
* 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层。
* `true`:只在非 quiet 拍撤 —— 人正对着件编辑的那几拍不动他的行(2026-08-29 终审 F3:
* 人刚在件上加的行,配方的数据源里当然没有,quiet 撤行就是静默数据丢失)。
* `'live'`(2026-08-31):quiet 拍也撤 —— **只给人不会在件上加行的表用**(行集全由配方铺,
* 如 W05 的参数骨架):F3 防的是「人刚加的行被撤」,人加不了行的表没有这个风险,
* 而「改了类型,旧类型的参数行还摆在屏上」是另一种撒谎 —— 跟手比守候更诚实。
* 已落地的行两档都跳过并出声(draftDrop 的 skipLanded)。
*/
readonly prune?: boolean | 'live' | undefined;
readonly from: AutoSource;
readonly fields: Readonly<Record<string, AutoExpr>>;
} | {
readonly kind: 'analyze';
readonly when?: string | undefined;
/** 活动声明过的模型名 */
readonly model: string;
/** 出参落进拍内作用域 `result:<into>`,供后面的 project 用 `each`/`lookup` 消费 */
readonly into: string;
/** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */
readonly instruction: string;
/** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */
readonly input: {
readonly from: AutoSource;
readonly fields: Readonly<Record<string, AutoExpr>>;
};
/** 出参 JSON Schema —— 顶层必须是 object */
readonly output: Readonly<Record<string, unknown>>;
} | {
readonly kind: 'card';
readonly when?: string | undefined;
readonly as: 'execution' | 'decide';
/**
* 卡的面孔:`data` 数据工单 / `accept` 验收工单(都是执行工单的变体)· `review` 审核卡(决策卡的)。
* 不写就是普通执行工单 / 普通决策卡。
*/
readonly variant?: ('data' | 'review' | 'accept') | undefined;
readonly title: AutoExpr;
/** 决策卡问的那句话 */
readonly ask?: AutoExpr | undefined;
/** 作业指导书 —— 一条一句,静态文本 */
readonly guide?: readonly string[] | undefined;
readonly standard?: AutoExpr | undefined;
/** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */
readonly basis?: readonly {
readonly k: string;
readonly v: AutoExpr;
}[] | undefined;
/** 这张卡的提交闸盯哪几张表 */
readonly gateLedgers?: readonly string[] | undefined;
/** 确认闸的条件档(S-FEEL,只读基座读数;为假不受理确认)+ 拒回那句话 —— 见 card.ts 同名字段 */
readonly gateWhen?: string | undefined;
readonly gateWhenSay?: string | undefined;
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */
readonly options?: readonly {
readonly value: string;
readonly label: string;
readonly detail?: string | undefined;
}[] | undefined;
} | {
readonly kind: 'submit';
readonly when?: string | undefined;
readonly rationale: AutoExpr;
} | {
readonly kind: 'say';
readonly when?: string | undefined;
readonly text: AutoExpr;
};
export declare const autoActionSchema: z.ZodType<AutoAction>;
export type AutoRecipe = {
/** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */
readonly let?: Readonly<Record<string, AutoExpr>> | undefined;
/** 按序执行 */
readonly do: readonly AutoAction[];
};
export declare const autoRecipeSchema: z.ZodType<AutoRecipe>;
+38
View File
@@ -604,6 +604,7 @@ export declare const workDoneSchema: z.ZodObject<{
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
export type WorkDone = z.infer<typeof workDoneSchema>;
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
@@ -612,6 +613,10 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
preset: z.ZodOptional<z.ZodEnum<{
"landing-failure": "landing-failure";
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -858,6 +863,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
gateWhen: z.ZodOptional<z.ZodString>;
gateWhenSay: z.ZodOptional<z.ZodString>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
@@ -881,6 +888,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
@@ -927,6 +935,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
settled: z.ZodOptional<z.ZodBoolean>;
rationale: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
label: z.ZodString;
effect: z.ZodEnum<{
@@ -1199,6 +1208,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -1217,6 +1227,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
@@ -1226,6 +1237,10 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
preset: z.ZodOptional<z.ZodEnum<{
"landing-failure": "landing-failure";
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
steps: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -1472,6 +1487,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
gateWhen: z.ZodOptional<z.ZodString>;
gateWhenSay: z.ZodOptional<z.ZodString>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
@@ -1495,6 +1512,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>, z.ZodObject<{
@@ -1513,6 +1531,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
settled: z.ZodOptional<z.ZodBoolean>;
rationale: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
label: z.ZodString;
effect: z.ZodEnum<{
@@ -1785,6 +1804,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -1803,6 +1823,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
pass: "pass";
}>;
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
}, z.core.$strip>], "mode">;
@@ -1819,6 +1840,10 @@ export declare const AI_TAG_LABEL: Readonly<Record<AiTag, string>>;
/** 决策卡:分支条件选择表;含自定义收尾项 */
export declare const decisionCardSchema: z.ZodObject<{
k: z.ZodLiteral<"decision">;
variant: z.ZodOptional<z.ZodEnum<{
review: "review";
}>>;
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
ask: z.ZodString;
basis: z.ZodArray<z.ZodObject<{
k: z.ZodString;
@@ -1838,6 +1863,7 @@ export declare const decisionCardSchema: z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
@@ -1931,6 +1957,10 @@ export declare const ratingCardSchema: z.ZodObject<{
export type RatingCard = z.infer<typeof ratingCardSchema>;
export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
k: z.ZodLiteral<"decision">;
variant: z.ZodOptional<z.ZodEnum<{
review: "review";
}>>;
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
ask: z.ZodString;
basis: z.ZodArray<z.ZodObject<{
k: z.ZodString;
@@ -1950,6 +1980,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
@@ -2064,6 +2095,10 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
at: z.ZodISODateTime;
}, z.core.$strip>, z.ZodObject<{
k: z.ZodLiteral<"decision">;
variant: z.ZodOptional<z.ZodEnum<{
review: "review";
}>>;
checklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
ask: z.ZodString;
basis: z.ZodArray<z.ZodObject<{
k: z.ZodString;
@@ -2083,6 +2118,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
at: z.ZodISODateTime;
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
note: z.ZodOptional<z.ZodString>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>;
finalHtml: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
@@ -2180,6 +2216,7 @@ export declare const workOrderReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
comment: z.ZodOptional<z.ZodString>;
selectedPorts: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>], "mode">;
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -2187,6 +2224,7 @@ export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
value: z.ZodString;
note: z.ZodOptional<z.ZodString>;
checked: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodObject<{
k: z.ZodLiteral<"elicitation">;
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
+2
View File
@@ -14,4 +14,6 @@ export * from './process-package.ts';
export * from './manifest.ts';
export * from './org.ts';
export * from './mail.ts';
export * from './activity-ops.ts';
export * from './auto-recipe.ts';
export * from './api/index.ts';
+1
View File
@@ -100,6 +100,7 @@ export declare const objectDeclarationSchema: z.ZodObject<{
derived: "derived";
}>>;
caseKey: z.ZodOptional<z.ZodString>;
createOnly: z.ZodOptional<z.ZodBoolean>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
+179
View File
@@ -48,6 +48,7 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
derived: "derived";
}>>;
caseKey: z.ZodOptional<z.ZodString>;
createOnly: z.ZodOptional<z.ZodBoolean>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
@@ -201,6 +202,13 @@ export declare const planStepSchema: z.ZodObject<{
view: z.ZodOptional<z.ZodString>;
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
doneWhen: z.ZodOptional<z.ZodString>;
cardVariant: z.ZodOptional<z.ZodEnum<{
data: "data";
review: "review";
}>>;
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
}, z.core.$strip>;
export type PlanStep = z.infer<typeof planStepSchema>;
/**
@@ -261,6 +269,13 @@ export declare const loopModelSchema: z.ZodObject<{
view: z.ZodOptional<z.ZodString>;
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
doneWhen: z.ZodOptional<z.ZodString>;
cardVariant: z.ZodOptional<z.ZodEnum<{
data: "data";
review: "review";
}>>;
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
}, z.core.$strip>>>;
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
@@ -315,6 +330,13 @@ export declare const businessModelSchema: z.ZodObject<{
view: z.ZodOptional<z.ZodString>;
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
doneWhen: z.ZodOptional<z.ZodString>;
cardVariant: z.ZodOptional<z.ZodEnum<{
data: "data";
review: "review";
}>>;
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
}, z.core.$strip>>>;
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
@@ -335,6 +357,95 @@ export declare const skillSchema: z.ZodObject<{
version: z.ZodString;
}, z.core.$strip>;
export type Skill = z.infer<typeof skillSchema>;
/**
* 运营中心沙箱看板的**取数声明**(2026-08-27):模版归活动,「哪几行是这一单的」也归活动 —— 案号前缀、
* 哪张表按哪一列圈,平台不该写死;基座只照声明逐表查**正式表**、把行集摊成扁平作用域交渲染器。
*
* 求值:先定案号 `case.prefix + 实例开案时刻 YYYYMMDDHHmmss`(与引擎注给模型铸主键的 `case:openedAt` 同一串);
* `rows` 按序求值,每条 = `ledger` 里 `by.column ∈ values(by.from)` 的行(`from` 只能引用 `case` 或前面已求出的
* 集合),可再用 `not` 排除一批、`orderBy` 排序。作用域里 `<as>` = 行数组、`<as>.<列>` = 首行标量、
* `<as 去符号>_rows` = 行数、`<as 去符号>_<列>_<值>` = tally 计数。案号那一行查无 → 这一单没落账,看板如实说。
*/
export declare const viewDataRowsSchema: z.ZodObject<{
as: z.ZodString;
ledger: z.ZodString;
by: z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>;
not: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>>;
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
field: z.ZodString;
dir: z.ZodEnum<{
desc: "desc";
asc: "asc";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>;
export type ViewDataRows = z.infer<typeof viewDataRowsSchema>;
export declare const viewDataSchema: z.ZodObject<{
case: z.ZodObject<{
ledger: z.ZodString;
key: z.ZodString;
prefix: z.ZodString;
}, z.core.$strip>;
rows: z.ZodArray<z.ZodObject<{
as: z.ZodString;
ledger: z.ZodString;
by: z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>;
not: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>>;
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
field: z.ZodString;
dir: z.ZodEnum<{
desc: "desc";
asc: "asc";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type ViewData = z.infer<typeof viewDataSchema>;
/** `by.from` / `not.from` 里指案号本身的那个字 */
export declare const VIEW_DATA_CASE_REF = "case";
export declare const viewTemplateSchema: z.ZodObject<{
name: z.ZodString;
html: z.ZodString;
data: z.ZodOptional<z.ZodObject<{
case: z.ZodObject<{
ledger: z.ZodString;
key: z.ZodString;
prefix: z.ZodString;
}, z.core.$strip>;
rows: z.ZodArray<z.ZodObject<{
as: z.ZodString;
ledger: z.ZodString;
by: z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>;
not: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>>;
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
field: z.ZodString;
dir: z.ZodEnum<{
desc: "desc";
asc: "asc";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type ViewTemplate = z.infer<typeof viewTemplateSchema>;
export declare const manifestAssetsSchema: z.ZodObject<{
businessModel: z.ZodObject<{
cmmnXml: z.ZodString;
@@ -378,6 +489,13 @@ export declare const manifestAssetsSchema: z.ZodObject<{
view: z.ZodOptional<z.ZodString>;
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
doneWhen: z.ZodOptional<z.ZodString>;
cardVariant: z.ZodOptional<z.ZodEnum<{
data: "data";
review: "review";
}>>;
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
}, z.core.$strip>>>;
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
@@ -399,6 +517,32 @@ export declare const manifestAssetsSchema: z.ZodObject<{
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
html: z.ZodString;
data: z.ZodOptional<z.ZodObject<{
case: z.ZodObject<{
ledger: z.ZodString;
key: z.ZodString;
prefix: z.ZodString;
}, z.core.$strip>;
rows: z.ZodArray<z.ZodObject<{
as: z.ZodString;
ledger: z.ZodString;
by: z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>;
not: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>>;
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
field: z.ZodString;
dir: z.ZodEnum<{
desc: "desc";
asc: "asc";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -537,6 +681,7 @@ export declare const activityManifestSchema: z.ZodObject<{
derived: "derived";
}>>;
caseKey: z.ZodOptional<z.ZodString>;
createOnly: z.ZodOptional<z.ZodBoolean>;
file: z.ZodOptional<z.ZodObject<{
titleColumn: z.ZodString;
bodyColumn: z.ZodString;
@@ -651,6 +796,13 @@ export declare const activityManifestSchema: z.ZodObject<{
view: z.ZodOptional<z.ZodString>;
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
doneWhen: z.ZodOptional<z.ZodString>;
cardVariant: z.ZodOptional<z.ZodEnum<{
data: "data";
review: "review";
}>>;
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
}, z.core.$strip>>>;
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString;
@@ -672,6 +824,32 @@ export declare const activityManifestSchema: z.ZodObject<{
viewTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
name: z.ZodString;
html: z.ZodString;
data: z.ZodOptional<z.ZodObject<{
case: z.ZodObject<{
ledger: z.ZodString;
key: z.ZodString;
prefix: z.ZodString;
}, z.core.$strip>;
rows: z.ZodArray<z.ZodObject<{
as: z.ZodString;
ledger: z.ZodString;
by: z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>;
not: z.ZodOptional<z.ZodObject<{
column: z.ZodString;
from: z.ZodString;
}, z.core.$strip>>;
orderBy: z.ZodOptional<z.ZodArray<z.ZodObject<{
field: z.ZodString;
dir: z.ZodEnum<{
desc: "desc";
asc: "asc";
}>;
}, z.core.$strip>>>;
}, z.core.$strip>>;
}, z.core.$strip>>;
}, z.core.$strip>>>;
cardSchemas: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
distillHooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -778,5 +956,6 @@ export declare const installVerdictSchema: z.ZodObject<{
}, z.core.$strip>>>;
skippedNoHistory: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>;
stored: z.ZodDefault<z.ZodBoolean>;
}, z.core.$strip>;
export type InstallVerdict = z.infer<typeof installVerdictSchema>;
+22
View File
@@ -10,3 +10,25 @@ export declare function isDefined<T>(value: T | null | undefined): value is T;
export declare function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]>;
/** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */
export declare function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]>;
export type TopoOrderResult = {
readonly ok: true;
readonly order: readonly string[];
} | {
readonly ok: false;
readonly cycle: readonly string[];
};
/**
* 拓扑排序(Kahn 算法)—— 给定节点集合与「这个节点依赖谁」,排出一个安全的处理序
* (被依赖的排前面)。
*
* 同族算法已有一份在 `apps/api/src/modules/process/internal/domain/routing.ts` 的
* `advanceOrder`(那是流程图专用的,找不齐拓扑序时把剩下的节点原样缀在末尾 ——
* 流程图的容错策略)。这里是它的**通用版**,故意搬到叶子包:两个不同层级的模块
* (`activity-registry` 装包静态审 / `loop-engine` 配方求值)都要用同一个算法判「排不排得出来」,
* 而模块分层不许它们互相 import ——图算法本身不含业务语义,该住在 `@utils`。
*
* **不兜底**:排不出来就是这几个节点之间有环,原样报出来,由调用方决定怎么处置
* (装包时机检报错 / 运行时交人),不在这一层替调用方决定「怎么办」。
* `dependsOn` 返回的名字如果不在 `nodes` 里,当作没这条依赖(忽略,不会因此误报成环)。
*/
export declare function topoOrder(nodes: readonly string[], dependsOn: (node: string) => readonly string[]): TopoOrderResult;
@@ -0,0 +1,52 @@
/** 实例五态:对 task 运行态 / 终态的如实映射(含「驳回是环内回转」那一段) */
import { describe, expect, it } from 'vitest';
import { instanceStateOf, verdictFactOf } from '../activity-ops/instance-state.ts';
const base = { terminal: undefined, reason: undefined, runtime: 'running', usedSteps: 3, lastVerdict: undefined, pending: undefined };
describe('instanceStateOf', () => {
it('终态:released 完成;terminated 失败且 kind=terminated;其余终态失败带原话', () => {
expect(instanceStateOf({ ...base, terminal: 'released' })).toEqual({ state: 'completed', reason: null });
expect(instanceStateOf({ ...base, terminal: 'terminated', reason: '由 柴浩 手动终止' })).toEqual({ state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } });
// exhausted 存的是预算执行器的 JSON —— 摆给人的要说哪一项用了多少;认不出的形状原样交
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"kind":"exhausted","breaches":[{"item":"steps","used":90,"cap":90},{"item":"activeMs","used":7200000,"cap":7200000}]}' }).reason)
.toEqual({ kind: 'failed', text: '预算耗尽:步数 90/90、活跃时长 120/120 分钟' });
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: '{"breaches":[]}' }).reason?.text).toBe('{"breaches":[]}');
expect(instanceStateOf({ ...base, terminal: 'exhausted', reason: undefined }).reason?.text).toBe('预算耗尽');
expect(instanceStateOf({ ...base, terminal: 'stalled' }).reason).toEqual({ kind: 'failed', text: '停滞熔断' });
});
it('待执行 = 建了单还没被推进器领到;等人回传仍是进行中', () => {
expect(instanceStateOf({ ...base, usedSteps: 0 }).state).toBe('pending');
expect(instanceStateOf({ ...base, runtime: 'awaiting-human', usedSteps: 0 }).state).toBe('running');
expect(instanceStateOf({ ...base, runtime: 'awaiting-event' }).state).toBe('running');
});
it('驳回:裁决刚到(pending.verdict=reject)或重做已被消费(pending 清空)且最近裁决不是 pass;重新提交后回进行中', () => {
const redo = { pass: false, comment: '毛利低于阈值,退回重算', byName: '柴浩' };
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: 'reject', comment: redo.comment } })).toEqual({
state: 'rejected',
reason: { kind: 'rejected', text: '验收驳回:毛利低于阈值,退回重算 —— 负责人 柴浩' },
});
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: undefined }).state).toBe('rejected');
// 新验收卡已发(pending 立起、还没裁决)→ running
expect(instanceStateOf({ ...base, lastVerdict: redo, pending: { verdict: undefined, comment: undefined } }).state).toBe('running');
// 上一次裁决是 pass(落地中)→ running
expect(instanceStateOf({ ...base, lastVerdict: { pass: true, comment: '', byName: '柴浩' }, pending: { verdict: 'pass', comment: undefined } }).state).toBe('running');
// 没写意见也要说人话
expect(instanceStateOf({ ...base, lastVerdict: { pass: false, comment: '', byName: undefined } }).reason?.text).toBe('验收驳回:负责人未写明具体意见,已退回重做');
});
});
describe('verdictFactOf', () => {
it('取最近一条 `裁决:`;落地那一路同 kind 但标题不同,不算', () => {
const entries = [
{ kind: 'release-verdict', title: '裁决:redo', detail: { by: 's-1', comment: '再核一遍' } },
{ kind: 'release-verdict', title: '裁决:pass', detail: { by: 's-1' } },
{ kind: 'release-verdict', title: '落地收敛,发射输出口:assessment-released', detail: { ports: ['assessment-released'] } },
];
expect(verdictFactOf(entries, (id) => (id === 's-1' ? '柴浩' : undefined))).toEqual({ pass: true, comment: '', byName: '柴浩' });
expect(verdictFactOf(entries.slice(0, 1))).toEqual({ pass: false, comment: '再核一遍', byName: undefined });
expect(verdictFactOf([])).toBeUndefined();
});
});
@@ -0,0 +1,353 @@
/**
* 启动参数表:从清单 `input.data` 派生 + 服务端校验。
* 夹具照 W05 / E04 的清单形状手写(apps 不得 import activities —— depcruise 在守)。
*/
import { describe, expect, it } from 'vitest';
import { controlOf, fieldSpecsOf, launchParamOf, launchParamsOf, validateLaunchParams } from '../activity-ops/launch-params.ts';
/** W05 的四参 + 选填文件,按 2026-08-27 的清单写法 */
const W05_INPUT = {
type: 'object',
required: ['customerCode', 'requestDescription', 'originalInternalPn', 'candidateInternalPns'],
properties: {
customerCode: {
type: 'string',
title: '客户',
description: '台账里有就直接选;新客户自己填',
examples: ['CU-0142'],
'x-control': 'combo',
'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' },
},
customerName: { type: 'string', title: '客户名称', examples: ['深圳恒星电源有限公司'] },
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea', examples: ['工业电源项目在用 LM2596S-5.0'] },
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' } },
candidateInternalPns: { type: 'array', title: '候选内部料号', items: { type: 'string' }, examples: [['XL2596S-5.0', 'LM2596S-ADJ']] },
datasheets: { type: 'array', format: 'file', title: 'datasheet', items: { type: 'object' } },
},
};
/** E04:两级下拉 + 数组(元素形状不声明)+ 选填说明 */
const E04_INPUT = {
type: 'object',
required: ['trigger'],
properties: {
trigger: { type: 'string', enum: ['monthly', 'event'], enumNames: ['月度例行', '事件驱动'], title: '触发方式', default: 'monthly' },
eventType: { type: 'string', enum: ['new_customer', 'major_deal'], title: '事件类型', showWhen: { field: 'trigger', equals: 'event' } },
customers: { type: 'array', title: '本轮涉及的客户', examples: [['CU-0142']] },
note: { type: 'string', title: '本轮说明' },
budget: { type: 'integer', title: '预算' },
},
};
const noResolve = async () => undefined;
describe('fieldSpecsOf / controlOf', () => {
it('保属性序;必填从 required[] 认;x-control / x-options / description 都读到', () => {
const specs = fieldSpecsOf(W05_INPUT);
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns', 'datasheets']);
expect(specs[0]).toMatchObject({ required: true, control: 'combo', description: '台账里有就直接选;新客户自己填', optionsSource: { ledger: 'customer.profile', value: 'customer_code', label: 'name' } });
// label 列没写就用 value 列当标签
expect(specs[3]?.optionsSource).toEqual({ ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' });
expect(specs[1]?.required).toBe(false);
});
it('控件推断:文件不上表单、字符串数组默认 list、enum 是 select、其余 text;数组标了 combo/select 就摆成单选', () => {
const specs = fieldSpecsOf(W05_INPUT);
expect(specs.map((s) => controlOf(s))).toEqual(['combo', 'text', 'textarea', 'combo', 'list', undefined]);
const e04 = fieldSpecsOf(E04_INPUT);
expect(e04.map((s) => controlOf(s))).toEqual(['select', 'select', 'list', 'text', 'text']);
// 数组字段标 combo → 摆成单选(提交时后端包成数组);标 list 或不标 → list
expect(controlOf({ ...specs[4]!, control: 'combo' })).toBe('combo');
expect(controlOf({ ...specs[4]!, control: 'list' })).toBe('list');
expect(controlOf({ ...specs[4]!, itemType: 'object' })).toBeUndefined();
expect(controlOf({ ...specs[1]!, type: 'object' })).toBeUndefined();
});
});
describe('x-order', () => {
it('声明了次序的按数字排,没声明的殿后保持原序(jsonb 不保键序,书写顺序靠不住)', () => {
const specs = fieldSpecsOf({
properties: {
customerCode: { type: 'string', 'x-order': 1 },
customerName: { type: 'string', 'x-order': 2 },
originalInternalPn: { type: 'string', 'x-order': 4 },
requestDescription: { type: 'string', 'x-order': 3 },
zz: { type: 'string' },
aa: { type: 'string' },
},
});
expect(specs.map((s) => s.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'zz', 'aa']);
});
});
describe('launchParamsOf', () => {
it('W05:五参(datasheets 省略);combo 候选来自 resolver;placeholder 取 examples[0]', async () => {
const asked: string[] = [];
const params = await launchParamsOf(W05_INPUT, async (src) => {
asked.push(src.ledger);
return src.ledger === 'customer.profile' ? [{ value: 'CU-0142', label: 'Itelma' }] : undefined;
});
expect(params.map((p) => p.key)).toEqual(['customerCode', 'customerName', 'requestDescription', 'originalInternalPn', 'candidateInternalPns']);
expect(asked).toEqual(['customer.profile', 'parts.profile']);
expect(params[0]).toEqual({
key: 'customerCode',
label: '客户',
type: 'combo',
required: true,
desc: '台账里有就直接选;新客户自己填',
placeholder: 'CU-0142',
options: [{ value: 'CU-0142', label: 'Itelma' }],
});
// 读不到候选的 combo 仍是 combo(自由填),只是没有 options
expect(params[3]).toEqual({ key: 'originalInternalPn', label: '原内部料号', type: 'combo', required: true });
expect(params[4]).toMatchObject({ type: 'list', placeholder: 'XL2596S-5.0', required: true });
expect(params[2]).toMatchObject({ type: 'textarea', placeholder: '工业电源项目在用 LM2596S-5.0' });
});
it('E04:enum → select 带人读名与 default;没 enumNames 拿值当标签;数组元素形状不声明也当 list', async () => {
const params = await launchParamsOf(E04_INPUT, noResolve);
expect(params[0]).toEqual({
key: 'trigger',
label: '触发方式',
type: 'select',
required: true,
options: [{ value: 'monthly', label: '月度例行' }, { value: 'event', label: '事件驱动' }],
default: 'monthly',
});
expect(params[1]?.options).toEqual([{ value: 'new_customer', label: 'new_customer' }, { value: 'major_deal', label: 'major_deal' }]);
expect(params[2]).toMatchObject({ type: 'list', placeholder: 'CU-0142' });
});
it('x-control:select 却一个候选都没有 → 降成 text(只能选却无可选等于锁死)', () => {
const spec = fieldSpecsOf({ properties: { k: { type: 'string', 'x-control': 'select' } } })[0]!;
expect(launchParamOf(spec, undefined)).toEqual({ key: 'k', label: 'k', type: 'text' });
expect(launchParamOf(spec, [])?.type).toBe('text');
});
});
describe('数组字段摆成单选(2026-08-27 用户定:候选料号与原料号同款下拉,只选一颗)', () => {
const INPUT = {
required: ['candidateInternalPns'],
properties: {
candidateInternalPns: {
type: 'array',
title: '候选内部料号',
items: { type: 'string' },
examples: [['XL2596S-5.0', 'LM2596S-ADJ']],
default: ['LM2596S-ADJ'],
'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn' },
},
tags: { type: 'array', items: { type: 'string' }, enum: ['a', 'b'], 'x-control': 'select' },
},
};
it('数组 + x-control:combo → combo 控件,占位与预填取第一颗,候选照读', async () => {
const params = await launchParamsOf(INPUT, async () => [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }]);
expect(params[0]).toEqual({
key: 'candidateInternalPns',
label: '候选内部料号',
type: 'combo',
required: true,
placeholder: 'XL2596S-5.0',
options: [{ value: 'LM2596S-ADJ', label: 'LM2596S-ADJ' }],
default: 'LM2596S-ADJ',
});
expect(params[1]).toMatchObject({ type: 'select', options: [{ value: 'a', label: 'a' }, { value: 'b', label: 'b' }] });
});
it('提交单个字符串包成一元数组;传数组也照收;空的按必填拦;select 逐项对候选', async () => {
const specs = fieldSpecsOf(INPUT);
const params = await launchParamsOf(INPUT, noResolve);
expect(validateLaunchParams(params, { candidateInternalPns: 'XL2596S-5.0' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0'] } });
expect(validateLaunchParams(params, { candidateInternalPns: ['XL2596S-5.0', ' LM2596S-ADJ '] }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] } });
const empty = validateLaunchParams(params, { candidateInternalPns: ' ' }, specs);
expect(empty).toEqual({ ok: false, issues: [{ key: 'candidateInternalPns', message: '必填' }] });
const off = validateLaunchParams(params, { candidateInternalPns: 'x', tags: ['a', 'z'] }, specs);
expect(off).toEqual({ ok: false, issues: [{ key: 'tags', message: '不在候选里(单选只能选候选)' }] });
expect(validateLaunchParams(params, { candidateInternalPns: 'x', tags: 'b' }, specs)).toEqual({ ok: true, data: { candidateInternalPns: ['x'], tags: ['b'] } });
});
});
describe('validateLaunchParams', () => {
it('必填空 / 未知键 / 类型不符 / select 不在候选 各点名一格;combo 候选之外合法', async () => {
const specs = fieldSpecsOf(W05_INPUT);
const params = await launchParamsOf(W05_INPUT, async () => [{ value: 'CU-0142', label: 'Itelma' }]);
const bad = validateLaunchParams(params, { customerCode: 'CU-NEW', requestDescription: '', candidateInternalPns: [' '], oops: 'x' }, specs);
expect(bad.ok).toBe(false);
if (!bad.ok) {
expect(bad.issues.map((i) => i.key).sort()).toEqual(['candidateInternalPns', 'oops', 'originalInternalPn', 'requestDescription']);
}
const typed = validateLaunchParams(params, { customerCode: ['CU-1'], requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: 'PN-1' }, specs);
expect(typed.ok).toBe(false);
// 标量格传数组是错;list 格传单个字符串**照收**(包成一元数组)
if (!typed.ok) expect(typed.issues).toEqual([{ key: 'customerCode', message: '要传字符串' }]);
const wrapped = validateLaunchParams(params, { customerCode: 'CU-1', requestDescription: 'x', originalInternalPn: 'p', candidateInternalPns: ' PN-1 ' }, specs);
expect(wrapped).toMatchObject({ ok: true, data: { candidateInternalPns: ['PN-1'] } });
const good = validateLaunchParams(
params,
{ customerCode: 'CU-NEW', customerName: '', requestDescription: ' 客户要求验证替代料 ', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', '', ' LM2596S-ADJ '] },
specs,
);
expect(good).toEqual({
ok: true,
// 可选空串不落入参;list 逐项去空白、丢空项;combo 候选之外原样收
data: { customerCode: 'CU-NEW', requestDescription: '客户要求验证替代料', originalInternalPn: 'LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'] },
});
});
it('select 只认候选;整数按清单类型转回数字', async () => {
const specs = fieldSpecsOf(E04_INPUT);
const params = await launchParamsOf(E04_INPUT, noResolve);
const bad = validateLaunchParams(params, { trigger: 'weekly' }, specs);
expect(bad.ok).toBe(false);
if (!bad.ok) expect(bad.issues[0]).toMatchObject({ key: 'trigger', message: expect.stringContaining('候选') });
expect(validateLaunchParams(params, { trigger: 'event', budget: '12' }, specs)).toEqual({ ok: true, data: { trigger: 'event', budget: 12 } });
const nan = validateLaunchParams(params, { trigger: 'event', budget: '1.5' }, specs);
expect(nan.ok).toBe(false);
});
});
/* ── 按分组键联动(2026-08-31 用户定:原料号 / 候选料号按芯片类型互相收窄)────────────
派生层只负责把两样东西送到前端:①候选带上它的分组键与人读名(`x-options.group`);
②这一格属于哪个联动组(`x-link`)。收窄怎么摆是前端的事,**服务端不据此立墙**。 */
describe('x-options.group / x-link', () => {
const LINKED = {
type: 'object',
required: ['originalInternalPn', 'candidateInternalPn'],
properties: {
originalInternalPn: {
type: 'string',
title: '原内部料号',
'x-control': 'combo',
'x-options': {
ledger: 'parts.profile',
value: 'internal_pn',
label: 'internal_pn',
group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' },
},
'x-link': 'chip-type',
'x-order': 1,
},
candidateInternalPn: {
type: 'string',
title: '候选内部料号',
'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: 'chip_type_id' },
'x-link': 'chip-type',
'x-order': 2,
},
},
};
it('分组键两种写法都认:整份声明(带字典)与只写列名;x-link 读成联动组名', () => {
const specs = fieldSpecsOf(LINKED);
expect(specs[0]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: { ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } });
// 只写列名 = 有分组键、没有人读名字典
expect(specs[1]?.optionsSource?.group).toEqual({ column: 'chip_type_id', dict: undefined });
expect(specs.map((s) => s.linkGroup)).toEqual(['chip-type', 'chip-type']);
});
it('分组键写残(没有 column,或字典缺 ledger/value)就当没声明 —— 候选照出,只是不联动', () => {
const bad = fieldSpecsOf({
properties: {
a: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: {} } },
b: { type: 'string', 'x-control': 'combo', 'x-options': { ledger: 'l.o', value: 'v', group: { column: 'c', ledger: 'd.t' } } },
},
});
expect(bad[0]?.optionsSource).toEqual({ ledger: 'l.o', value: 'v', label: 'v', group: undefined });
// 字典写残 → 只留分组键,人读名没有(不炸)
expect(bad[1]?.optionsSource?.group).toEqual({ column: 'c', dict: undefined });
});
it('候选带 group / groupLabel 原样交给前端;有候选才给 linkGroup(一个候选都没有,联动无从谈起)', async () => {
const params = await launchParamsOf(LINKED, async (src) =>
src.group === undefined
? [{ value: 'AMS1117-3.3', label: 'AMS1117-3.3' }]
: [
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
{ value: 'OLD-001', label: 'OLD-001', group: '' },
],
);
expect(params[0]).toMatchObject({
key: 'originalInternalPn',
linkGroup: 'chip-type',
options: [
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
{ value: 'OLD-001', label: 'OLD-001', group: '' },
],
});
// 候选读不到 → 仍是 combo,但不摆联动组(前端没有可收窄的东西)
const empty = await launchParamsOf(LINKED, noResolve);
expect(empty[0]?.linkGroup).toBeUndefined();
expect(empty[0]?.type).toBe('combo');
});
it('服务端不据分组立墙:两格填不同类型的料号照样收(类型一致归活动的收步判据管)', async () => {
const params = await launchParamsOf(LINKED, async () => [
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1' },
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' },
]);
const out = validateLaunchParams(params, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' }, fieldSpecsOf(LINKED));
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' } });
});
});
/**
* 互斥组(2026-08-31 用户定:「不应该能选到同样的两个芯片」)。
* 与 `x-link` 正相反 —— **这一条是墙**:W05 两格同一颗料会把单子做死(两行撞同一个主键收成一行,
* S1 的 `exists_status_found = 2` 永不成立,而件上那两行人加不出来),而唯一能改的时机就是起单这一刻。
*/
describe('x-distinct', () => {
const DISTINCT = {
type: 'object',
required: ['originalInternalPn', 'candidateInternalPn'],
properties: {
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 1 },
candidateInternalPn: { type: 'string', title: '候选内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 2 },
/* 同一张表上不在这一组的格子:填成一样也与它无关 */
note: { type: 'string', title: '备注' },
},
};
const paramsOf = async () => launchParamsOf(DISTINCT, async () => [{ value: 'LM2596S-5.0', label: 'LM2596S-5.0' }, { value: 'XL2596S-5.0', label: 'XL2596S-5.0' }]);
it('读得到组名;**不以有候选为前提** —— 自由输入的两格同样归它管(墙管的是值,不是候选)', async () => {
expect(fieldSpecsOf(DISTINCT).map((s) => s.distinctGroup)).toEqual(['part', 'part', undefined]);
const bare = await launchParamsOf({ properties: { a: { type: 'string', 'x-distinct': 'g' }, b: { type: 'string', 'x-distinct': '' } } }, noResolve);
expect(bare.map((p) => [p.type, p.distinctGroup])).toEqual([
['text', 'g'],
['text', undefined],
]);
});
it('两格同一个值 → 拒,点名**后填的那格**,话里指向它撞上了谁', async () => {
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' }, fieldSpecsOf(DISTINCT));
expect(out).toEqual({ ok: false, issues: [{ key: 'candidateInternalPn', message: '与「原内部料号」填的是同一个 —— 这两格不能一样,换一个再启动' }] });
});
it('**去空白 + 不分大小写**:只挡精确相等的话,`lm2596s-5.0` 会照样进去,混一颗垃圾主数据出来', async () => {
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' }, fieldSpecsOf(DISTINCT));
expect(out.ok).toBe(false);
});
it('两颗不同的料照收;不在这一组的格子填成一样也不管', async () => {
const out = validateLaunchParams(
await paramsOf(),
{ originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' },
fieldSpecsOf(DISTINCT),
);
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' } });
});
it('空的不算撞上 —— 两格都没填该报的是必填,不是「填了同一个」', async () => {
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: '', candidateInternalPn: '' }, fieldSpecsOf(DISTINCT));
expect(out).toEqual({ ok: false, issues: [{ key: 'originalInternalPn', message: '必填' }, { key: 'candidateInternalPn', message: '必填' }] });
});
it('数组格不参与(契约写死的边界:本期没有这种形状)', async () => {
const arrays = { properties: { a: { type: 'array', items: { type: 'string' }, title: 'A', 'x-distinct': 'g' }, b: { type: 'array', items: { type: 'string' }, title: 'B', 'x-distinct': 'g' } } };
const out = validateLaunchParams(await launchParamsOf(arrays, noResolve), { a: ['X'], b: ['X'] }, fieldSpecsOf(arrays));
expect(out).toEqual({ ok: true, data: { a: ['X'], b: ['X'] } });
});
});
@@ -0,0 +1,602 @@
/**
* 活动模块用例:投影 / 启动(幂等三档)/ 实例清单与详情 / 终止 / 禁用。
* 依赖全是内存字面量 —— 服务只声明用到的那几面,不用造整本清单。
*/
import { describe, expect, it } from 'vitest';
import type { LaunchInput, TaskId } from '@contracts';
import { staffIdSchema, taskIdSchema } from '@contracts';
import type { ActivityCard } from '@modules/activity-registry';
import { parseCmmn } from '@modules/loop-engine';
import type { ItemRuntime } from '@modules/loop-engine';
import { StaffNotFoundError } from '@modules/org';
import type { StartTaskInput } from '@modules/task';
import { createActivityOps, LAUNCH_CLAIM_STALE_MS } from '../activity-ops/service.ts';
import type { ActivityOpsDeps, HistoryEntry, LoopFace, ManifestFace } from '../activity-ops/service.ts';
import type { ActivityOpsStore, ActivityProfile, LaunchClaim, TaskRow } from '../activity-ops/store.ts';
const W05 = 'part-substitution-assessment';
const OWNER = staffIdSchema.parse('s-owner');
const ME = { id: staffIdSchema.parse('s-me'), name: '冯兵' };
const XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
<case id="c" name="试"><casePlanModel id="p" name="p">
<humanTask id="S1" name="归一"/>
<humanTask id="S2" name="交人确认"><itemControl><manualActivationRule id="m"/></itemControl></humanTask>
<humanTask id="T_release" name="放行"/>
</casePlanModel></case>
</definitions>`;
const card = (over: Partial<ActivityCard> = {}): ActivityCard => ({
activityId: W05,
name: 'ACT-WORK-05 · 差异评估',
category: '需求分析与方案匹配',
packageVersions: [
{ activityId: W05, version: '1.4.9', installedAt: '2026-08-26T01:00:00.000Z', state: 'active' },
{ activityId: W05, version: '1.5.0', installedAt: '2026-08-27T01:00:00.000Z', state: 'active' },
],
assetVersions: [],
enabled: true,
...over,
});
const manifest = (over: Partial<ManifestFace> = {}): ManifestFace => ({
identity: { name: 'ACT-WORK-05 · 差异评估' },
contract: {
input: {
data: {
type: 'object',
required: ['customerCode', 'requestDescription', 'candidateInternalPns'],
properties: {
customerCode: { type: 'string', title: '客户', 'x-control': 'combo', 'x-options': { ledger: 'customer.profile', value: 'customer_code', label: 'name' } },
requestDescription: { type: 'string', title: '客户需求', 'x-control': 'textarea' },
candidateInternalPns: { type: 'array', title: '候选料号', items: { type: 'string' } },
datasheets: { type: 'array', format: 'file', items: { type: 'object' } },
},
},
},
},
declarations: {
models: ['deepseek-v4-flash'],
ledger: [
{ domain: 'sub_eval', object: 'verdict', fields: [{ name: 'eval_ref' }, { name: 'request_id' }] },
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
],
},
assets: { viewTemplates: [{ name: 'activity-ledger', html: '<html><body><h1>一览</h1></body></html>' }] },
...over,
});
/** 一张带取数声明的看板模版:案号 → 核对表 → 料号圈主数据,页头摆实例与案号 */
const BOARD_HTML = [
'<html><body><div class="page" data-class-of="board:state">',
'<p><b data-slot="case:instanceNo"></b> · <i data-slot="case:ref"></i> · <u data-slot="case:owner"></u></p>',
'<p>需求 <span data-slot="sub_eval.verdict.request_id"></span> · 料号 <span data-slot="parts_profile_rows"></span> 条 · NRND <span data-slot="parts_profile_lifecycle_status_nrnd"></span></p>',
'<table><tbody data-rows="parts.profile"><tr><td data-slot="internal_pn"></td><td><span class="st" data-class-of="lifecycle_status"></span></td></tr></tbody></table>',
'</div></body></html>',
].join('');
const BOARD_DATA = {
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
rows: [
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' } },
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
],
};
const boardManifest = (): ManifestFace => manifest({ assets: { viewTemplates: [{ name: 'activity-ledger', html: BOARD_HTML, data: BOARD_DATA }] } });
/** 正式表的一角:两单的行并排躺着,看板只许圈出案号对上的那一单 */
const FORMAL: Record<string, Array<Record<string, unknown>>> = {
'sub_eval.verdict': [
{ eval_ref: 'W05-20260827100000', request_id: 'REQ-20260827100000' },
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000' },
],
'sub_eval.pn_check': [
{ eval_ref: 'W05-20260827100000', internal_pn: 'LM2596S-5.0' },
{ eval_ref: 'W05-20260827100000', internal_pn: 'XL2596S-5.0' },
{ eval_ref: 'W05-20260827000000', internal_pn: 'OTHER' },
],
'parts.profile': [
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
{ internal_pn: 'OTHER', lifecycle_status: 'active' },
],
};
const formalLedger: ActivityOpsDeps['ledger'] = {
describe: async (address) => (FORMAL[`${address.domain}.${address.object}`] === undefined ? undefined : { columns: [] }),
query: async (q) => ({
rows: (FORMAL[`${q.address.domain}.${q.address.object}`] ?? []).filter((r) => Object.entries(q.where).every(([k, v]) => String(r[k]) === String(v))),
}),
};
/** 本地时区里 2026-08-27 10:00:00 起的单 —— 案号 W05-20260827100000 */
const RELEASED_AT = new Date(2026, 7, 27, 10, 0, 0).toISOString();
const row = (over: Partial<TaskRow> = {}): TaskRow => ({
id: 't-1',
no: 'INS-7',
activityId: W05,
workflowId: null,
origin: 'ops',
ownerId: OWNER,
title: 'ACT-WORK-05 · 差异评估',
runtime: 'running',
terminal: null,
reason: null,
envelope: { packageVersion: '1.5.0' },
data: { customerCode: 'CU-0142', requestDescription: '工业电源项目在用 LM2596S-5.0', candidateInternalPns: ['XL2596S-5.0'], datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 3, ref: 'u-1' }] },
usedSteps: 2,
startedAt: '2026-08-27T02:00:00.000Z',
finishedAt: null,
...over,
});
function memoryStore(rows: TaskRow[] = [], profiles: ActivityProfile[] = []) {
const claims = new Map<string, LaunchClaim>();
const profileMap = new Map(profiles.map((p) => [p.activityId, p]));
const store: ActivityOpsStore = {
async countByActivity() {
const m = new Map<string, number>();
for (const r of rows) m.set(r.activityId, (m.get(r.activityId) ?? 0) + 1);
return m;
},
async listTasks(activityId) {
return rows.filter((r) => r.activityId === activityId);
},
async getTask(activityId, taskId) {
return rows.find((r) => r.activityId === activityId && r.id === taskId);
},
async latestReleased(activityId) {
return rows
.filter((r) => r.activityId === activityId && r.terminal === 'released')
.sort((a, b) => Date.parse(b.finishedAt ?? b.startedAt) - Date.parse(a.finishedAt ?? a.startedAt))[0];
},
async claimLaunch(input) {
const hit = claims.get(input.key);
if (hit !== undefined) return hit;
claims.set(input.key, { ...input, taskId: null, claimedAt: clock.now().toISOString() });
return 'claimed';
},
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
const hit = claims.get(key);
if (hit === undefined || hit.taskId !== null || clock.now().getTime() - Date.parse(hit.claimedAt) < staleMs) return false;
claims.set(key, { ...hit, ownerId, launchedBy, claimedAt: clock.now().toISOString() });
return true;
},
async bindLaunch(key, taskId) {
const hit = claims.get(key);
if (hit !== undefined) claims.set(key, { ...hit, taskId });
},
async launchOf(taskId) {
return [...claims.values()].find((c) => c.taskId === taskId);
},
async profilesOf() {
return profileMap;
},
async upsertProfile(p) {
profileMap.set(p.activityId, p);
},
async workflowOf() {
return { subject: '统一报价 · Itelma', source: { kind: 'event' } };
},
async inboundCommOf(taskId) {
return taskId === 't-mail' ? { channel: 'mail', party: 'quotes@itelma.ru', subject: 'RFQ' } : undefined;
},
};
const clock = { at: Date.parse('2026-08-27T03:00:00.000Z'), now: () => new Date(clock.at) };
return { store, claims, clock, rows };
}
function harness(
opts: {
rows?: TaskRow[];
profiles?: ActivityProfile[];
enabled?: boolean;
history?: Record<string, HistoryEntry[]>;
loops?: Record<string, LoopFace>;
manifest?: ManifestFace;
ledger?: ActivityOpsDeps['ledger'];
} = {},
) {
const mem = memoryStore(opts.rows ?? [], opts.profiles ?? []);
const started: StartTaskInput[] = [];
const controlled: Array<{ taskId: TaskId; reason: string | undefined }> = [];
let enabled = opts.enabled ?? true;
let n = 0;
const staff = [
{ id: OWNER, name: '柴浩', state: 'active' },
{ id: ME.id, name: ME.name, state: 'active' },
{ id: 's-gone', name: '离职者', state: 'disabled' },
];
const deps: ActivityOpsDeps = {
registry: {
list: async () => [card({ enabled })],
get: async (id) => (id === W05 ? card({ enabled }) : undefined),
manifestOf: async () => opts.manifest ?? manifest(),
setEnabled: async (_id, on) => {
enabled = on;
},
},
task: {
start: async (input) => {
started.push(input);
n += 1;
const id = taskIdSchema.parse(`t-new-${n}`);
mem.rows.push(row({ id, no: `INS-${100 + n}`, ownerId: input.ownerId, data: input.data, usedSteps: 0, origin: input.origin ?? null }));
return { id };
},
control: async (taskId, _semantic, _by, reason) => {
controlled.push({ taskId, reason });
const at = mem.rows.findIndex((r) => r.id === taskId);
const hit = mem.rows[at];
if (hit !== undefined) mem.rows.splice(at, 1, { ...hit, terminal: 'terminated', reason: reason ?? null, finishedAt: mem.clock.now().toISOString() });
return {};
},
},
org: {
listStaff: async () => ({ staff }),
getStaff: async (id) => {
const hit = staff.find((s) => s.id === id);
if (hit === undefined) throw new StaffNotFoundError(id);
return hit;
},
},
memory: { history: async (taskId) => opts.history?.[taskId] ?? [] },
ledger: opts.ledger ?? {
describe: async (address) => (address.object === 'profile' ? { columns: [{ name: 'customer_code' }, { name: 'name' }] } : undefined),
query: async () => ({ rows: [{ customer_code: 'CU-0142', name: 'Itelma' }, { customer_code: 'CU-0001', name: '' }] }),
},
loopState: { load: async (taskId) => opts.loops?.[taskId] },
store: mem.store,
files: {
readUpload: async (ref) => (ref === 'u-1' ? { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : undefined),
listArchived: async (taskId) => (taskId === 't-done' ? [{ address: 'content.asset', key: { id: '1' }, format: 'docx', name: '报告.docx', size: 9 }] : []),
readArchived: async () => ({ bytes: new Uint8Array([9]), name: '报告.docx', format: 'docx' }),
},
clock: mem.clock,
warn: () => undefined,
};
return { ops: createActivityOps(deps), mem, started, controlled };
}
const launchInput = (over: Partial<LaunchInput> = {}): LaunchInput => ({
idempotencyKey: 'k-1',
ownerId: OWNER,
params: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] },
...over,
});
describe('活动投影', () => {
it('清单:版本取最新一版、创建时刻取最早一版、负责人与图标来自档案、实例按活动数;status 过滤', async () => {
const { ops } = harness({
rows: [row(), row({ id: 't-2', no: 'INS-8' })],
profiles: [{ activityId: W05, ownerId: OWNER, description: '逐项差异评估', ico: 'git-compare', hue: 'teal' }],
});
const [a] = await ops.listActivities();
expect(a).toEqual({
id: W05,
name: 'ACT-WORK-05 · 差异评估',
biz: '需求分析与方案匹配',
status: 'active',
version: '1.5.0',
owner: { id: OWNER, name: '柴浩' },
llm: 'deepseek-v4-flash',
instanceCount: 2,
createdAt: '2026-08-26T01:00:00.000Z',
ico: 'git-compare',
hue: 'teal',
});
expect(await ops.listActivities('disabled')).toEqual([]);
const detail = await ops.getActivity(W05);
expect(detail).toMatchObject({ desc: '逐项差异评估', view: { url: `/api/gov/activities/${W05}/view` } });
expect(detail).not.toHaveProperty('instanceCount');
expect(await ops.getActivity('nope')).toBeUndefined();
});
it('没建档:负责人 null、说明空串、图标色相缺席;色相不在四色里也不摆', async () => {
const { ops } = harness({ profiles: [{ activityId: W05, ownerId: null, description: '', ico: null, hue: 'pink' }] });
const [a] = await ops.listActivities();
expect(a).toMatchObject({ owner: null, instanceCount: 0 });
expect(a).not.toHaveProperty('ico');
expect(a).not.toHaveProperty('hue');
});
it('禁用 / 激活幂等,回活动详情;视图注入报高脚本、没配视图 404', async () => {
const { ops } = harness();
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
expect((await ops.setEnabled(W05, false))?.status).toBe('disabled');
expect((await ops.setEnabled(W05, true))?.status).toBe('active');
expect(await ops.setEnabled('nope', true)).toBeUndefined();
const view = await ops.viewHtml(W05);
expect(view.kind).toBe('html');
if (view.kind === 'html') expect(view.html).toContain('jcai:view-height');
expect((await ops.viewHtml('nope')).kind).toBe('no-activity');
});
});
describe('沙箱看板:最近一次已放行实例的落账快照', () => {
const released = (over: Partial<TaskRow> = {}) =>
row({ id: 't-rel', no: 'INS-20', runtime: 'terminal', terminal: 'released', startedAt: RELEASED_AT, finishedAt: new Date(2026, 7, 27, 10, 6, 0).toISOString(), ...over });
it('按案号只圈这一单的行:页头实例号 / 案号 / 负责人,料号两颗、别单的 OTHER 不进来,徽标按值投影,读数摆出来', async () => {
const older = released({ id: 't-old', no: 'INS-3', startedAt: new Date(2026, 7, 27, 0, 0, 0).toISOString(), finishedAt: new Date(2026, 7, 27, 0, 5, 0).toISOString() });
const { ops } = harness({ rows: [row({ id: 't-live' }), older, released()], manifest: boardManifest(), ledger: formalLedger });
const view = await ops.viewHtml(W05);
expect(view.kind).toBe('html');
if (view.kind !== 'html') return;
expect(view.html).toContain('class="page v-ready"');
expect(view.html).toContain('<b>INS-20</b> · <i>W05-20260827100000</i> · <u>柴浩</u>');
expect(view.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>2</span> 条 · NRND <span>1</span>');
expect(view.html).toContain('<td>LM2596S-5.0</td><td><span class="st v-nrnd"></span></td>');
expect(view.html).toContain('<td>XL2596S-5.0</td><td><span class="st v-active"></span></td>');
expect(view.html).not.toContain('OTHER');
expect(view.html).not.toContain('data-rows=');
expect(view.html).toContain('jcai:view-height');
});
it('没放行过 → none 态、实例事实空着;案号在正式表里查无 → missing 态、表全空(不摆别单的行)', async () => {
const none = harness({ rows: [row({ id: 't-live' })], manifest: boardManifest(), ledger: formalLedger });
const v1 = await none.ops.viewHtml(W05);
if (v1.kind === 'html') {
expect(v1.html).toContain('class="page v-none"');
expect(v1.html).toContain('<b>—</b>');
expect(v1.html).toContain('料号 <span>0</span>');
} else expect.unreachable();
const missing = harness({ rows: [released({ startedAt: new Date(2026, 7, 27, 11, 0, 0).toISOString() })], manifest: boardManifest(), ledger: formalLedger });
const v2 = await missing.ops.viewHtml(W05);
if (v2.kind === 'html') {
expect(v2.html).toContain('class="page v-missing"');
expect(v2.html).toContain('W05-20260827110000');
expect(v2.html).not.toContain('LM2596S-5.0');
} else expect.unreachable();
});
it('一张表读不了不拖垮整屏:那一块空着,别的照摆;模版没带 data 就原样出', async () => {
const half: ActivityOpsDeps['ledger'] = { ...formalLedger, describe: async (a) => (a.object === 'profile' ? undefined : formalLedger.describe(a)) };
const { ops } = harness({ rows: [released()], manifest: boardManifest(), ledger: half });
const v = await ops.viewHtml(W05);
if (v.kind === 'html') {
expect(v.html).toContain('class="page v-ready"');
expect(v.html).toContain('需求 <span>REQ-20260827100000</span> · 料号 <span>0</span>');
} else expect.unreachable();
const plain = harness({ rows: [released()] });
const p = await plain.ops.viewHtml(W05);
if (p.kind === 'html') expect(p.html).toContain('<h1>一览</h1>');
else expect.unreachable();
});
});
describe('启动', () => {
it('参数表:combo 候选从台账读(空标签退回值);文件字段不上表', async () => {
const { ops } = harness();
const table = await ops.launchParams(W05);
expect(table?.version).toBe('1.5.0');
expect(table?.params.map((p) => p.key)).toEqual(['customerCode', 'requestDescription', 'candidateInternalPns']);
expect(table?.params[0]?.options).toEqual([{ value: 'CU-0142', label: 'Itelma' }, { value: 'CU-0001', label: 'CU-0001' }]);
});
/* 联动(2026-08-31 用户定):候选行上多取一列当分组键,再从字典表换人读名交给前端 */
const LINKED_MANIFEST = (): ManifestFace =>
manifest({
contract: {
input: {
data: {
type: 'object',
required: ['originalInternalPn', 'candidateInternalPn'],
properties: {
originalInternalPn: {
type: 'string',
title: '原内部料号',
'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
'x-order': 1,
'x-link': 'chip-type',
},
candidateInternalPn: {
type: 'string',
title: '候选内部料号',
'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
'x-order': 2,
'x-link': 'chip-type',
},
},
},
},
},
});
const PARTS: Record<string, Record<string, unknown>[]> = {
'parts.profile': [
{ internal_pn: 'AMS1117-3.3', chip_type_id: '2' },
{ internal_pn: 'LM2596S-5.0', chip_type_id: '1' },
{ internal_pn: 'OLD-001', chip_type_id: null },
],
'parts.chip_type': [
{ type_id: '1', type_name: 'DC-DC 降压稳压器' },
{ type_id: '2', type_name: 'LDO 线性稳压器' },
],
};
const partsLedger = (over: Partial<ActivityOpsDeps['ledger']> = {}): ActivityOpsDeps['ledger'] => ({
describe: async (address) => {
const rows = PARTS[`${address.domain}.${address.object}`];
return rows === undefined ? undefined : { columns: Object.keys(rows[0] ?? {}).map((name) => ({ name })) };
},
query: async (q) => ({ rows: PARTS[`${q.address.domain}.${q.address.object}`] ?? [] }),
...over,
});
it('候选带分组键 + 字典换来的人读名;那一列没值就是空串(未标类型),联动组名随参数下发', async () => {
const { ops } = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger() });
const table = await ops.launchParams(W05);
expect(table?.params.map((p) => p.linkGroup)).toEqual(['chip-type', 'chip-type']);
expect(table?.params[0]?.options).toEqual([
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2', groupLabel: 'LDO 线性稳压器' },
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
{ value: 'OLD-001', label: 'OLD-001', group: '' },
]);
});
it('分组那一列不存在 / 字典读不到 → 候选照出,只是没有键或没有名(不炸)', async () => {
const noColumn = harness({
manifest: LINKED_MANIFEST(),
ledger: partsLedger({ describe: async (a) => (a.object === 'profile' ? { columns: [{ name: 'internal_pn' }] } : { columns: [{ name: 'type_id' }, { name: 'type_name' }] }) }),
});
const t1 = await noColumn.ops.launchParams(W05);
expect(t1?.params[0]?.options?.every((o) => o.group === undefined)).toBe(true);
// 分组键在、字典表没物化 → 有键没名
const noDict = harness({ manifest: LINKED_MANIFEST(), ledger: partsLedger({ describe: async (a) => (a.object === 'chip_type' ? undefined : { columns: [{ name: 'internal_pn' }, { name: 'chip_type_id' }] }) }) });
const t2 = await noDict.ops.launchParams(W05);
expect(t2?.params[0]?.options?.[0]).toEqual({ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2' });
});
it('禁用 412 / 责任人不在职 400 / 参数不合 400 —— 都不起单', async () => {
const { ops, started } = harness({ enabled: false });
expect((await ops.launch(W05, launchInput(), ME)).kind).toBe('disabled');
const live = harness();
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-gone') }), ME)).toMatchObject({ kind: 'owner-invalid' });
expect(await live.ops.launch(W05, launchInput({ ownerId: staffIdSchema.parse('s-nobody') }), ME)).toMatchObject({ kind: 'owner-invalid', message: '责任人不在名册里' });
const bad = await live.ops.launch(W05, launchInput({ params: { customerCode: 'CU-0142' } }), ME);
expect(bad.kind).toBe('invalid');
if (bad.kind === 'invalid') expect(bad.issues.map((i) => i.key)).toEqual(['requestDescription', 'candidateInternalPns']);
expect((await live.ops.launch('nope', launchInput(), ME)).kind).toBe('not-found');
expect(started).toEqual([]);
expect(live.started).toEqual([]);
});
it('起单:origin=ops、标题取清单名、入参归一后交 task.start;同键重放回同一单、不起第二个', async () => {
const { ops, started } = harness();
const first = await ops.launch(W05, launchInput(), ME);
expect(first.kind).toBe('ok');
if (first.kind !== 'ok') return;
expect(first.created).toBe(true);
expect(started).toEqual([
{ activityId: W05, ownerId: OWNER, title: 'ACT-WORK-05 · 差异评估', origin: 'ops', data: { customerCode: 'CU-0142', requestDescription: '验证替代料', candidateInternalPns: ['XL2596S-5.0'] } },
]);
expect(first.detail).toMatchObject({ id: 't-new-1', state: 'pending', owner: { id: OWNER, name: '柴浩' }, input: { source: { kind: 'manual', label: '手动启动 · 冯兵' } } });
const again = await ops.launch(W05, launchInput({ params: { customerCode: 'X', requestDescription: 'y', candidateInternalPns: ['z'] } }), ME);
expect(again).toMatchObject({ kind: 'ok', created: false, detail: { id: 't-new-1' } });
expect(started).toHaveLength(1);
});
it('键占了单还没建成:60 秒内 409;过期后重占再起', async () => {
const h = harness();
await h.mem.store.claimLaunch({ key: 'k-1', activityId: W05, ownerId: OWNER, launchedBy: 's-x' });
expect(await h.ops.launch(W05, launchInput(), ME)).toMatchObject({ kind: 'busy' });
h.mem.clock.at += LAUNCH_CLAIM_STALE_MS + 1;
const late = await h.ops.launch(W05, launchInput(), ME);
expect(late.kind).toBe('ok');
expect(h.started).toHaveLength(1);
expect(h.mem.claims.get('k-1')?.taskId).toBe('t-new-1');
// 别的活动拿同一枚键 → 409
await h.mem.store.claimLaunch({ key: 'k-2', activityId: 'other', ownerId: OWNER, launchedBy: 's-x' });
expect(await h.ops.launch(W05, launchInput({ idempotencyKey: 'k-2' }), ME)).toMatchObject({ kind: 'busy' });
});
});
describe('实例清单与详情', () => {
const items = parseCmmn(XML).items;
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
const loop = (states: Record<string, ItemRuntime['state']>, pending?: LoopFace['pending']): LoopFace => ({
model: { steps: [{ item: 'S1', intent: '把四项绑定' }] },
parsed: { items },
runtime: { items: rt(states) },
...(pending === undefined ? {} : { pending }),
});
const line = (kind: string, title: string, at: string, detail?: Record<string, unknown>): HistoryEntry => ({ kind, title, at, ...(detail === undefined ? {} : { detail }) });
const rows = [
row({ id: 't-1', no: 'INS-7' }),
row({ id: 't-redo', no: 'INS-6', startedAt: '2026-08-26T02:00:00.000Z' }),
row({ id: 't-done', no: 'INS-5', terminal: 'released', runtime: 'terminal', finishedAt: '2026-08-25T03:00:00.000Z', startedAt: '2026-08-25T02:00:00.000Z' }),
row({ id: 't-dead', no: 'INS-4', terminal: 'terminated', reason: '由 冯兵 手动终止', finishedAt: '2026-08-24T02:30:00.000Z', startedAt: '2026-08-24T02:00:00.000Z', ownerId: ME.id }),
row({ id: 't-mail', no: 'INS-3', origin: null, usedSteps: 0, data: {}, startedAt: '2026-08-23T02:00:00.000Z' }),
];
const history = {
't-redo': [line('release-verdict', '裁决:redo', '2026-08-26T03:00:00.000Z', { by: OWNER, comment: '证据不足' })],
't-done': [line('release-verdict', '落地收敛,发射输出口:assessment-released', '2026-08-25T03:00:00.000Z', { ports: ['assessment-released'] })],
't-1': [line('engine-transition', '开始办:归一', '2026-08-27T02:00:10.000Z'), line('engine-transition', '完成:归一', '2026-08-27T02:01:10.000Z'), line('engine-transition', '开始办:交人确认', '2026-08-27T02:01:20.000Z')],
};
const loops = { 't-1': loop({ S1: 'Completed', S2: 'Active' }), 't-redo': loop({ S1: 'Completed', S2: 'Completed', T_release: 'Completed' }) };
it('五态各归其位;统计不随筛选动;产出只给已完成的;分页按筛后走', async () => {
const { ops } = harness({ rows, history, loops });
const all = await ops.listInstances(W05, { page: 1, size: 8 });
expect(all?.stats).toEqual({ total: 5, byState: { pending: 1, running: 1, completed: 1, rejected: 1, failed: 1 } });
expect(all?.items.map((i) => [i.no, i.state, i.output])).toEqual([
['INS-7', 'running', null],
['INS-6', 'rejected', null],
['INS-5', 'completed', '报告.docx'],
['INS-4', 'failed', null],
['INS-3', 'pending', null],
]);
expect(all?.items[0]?.input).toBe('工业电源项目在用 LM2596S-5.0');
// 入参空的那单退回来件主题
expect(all?.items[4]?.input).toBe('RFQ');
const page2 = await ops.listInstances(W05, { page: 2, size: 2, ownerId: OWNER });
expect(page2).toMatchObject({ total: 4, page: 2, size: 2 });
expect(page2?.items.map((i) => i.no)).toEqual(['INS-5', 'INS-3']);
expect(page2?.stats.total).toBe(5);
expect((await ops.listInstances(W05, { page: 1, size: 8, id: 'ins-4' }))?.items.map((i) => i.no)).toEqual(['INS-4']);
expect(await ops.listInstances('nope', { page: 1, size: 8 })).toBeUndefined();
});
it('详情:时间线 / 来源 / 文件 / 原因 / 用时', async () => {
const { ops } = harness({ rows, history, loops });
const live = await ops.instanceDetail(W05, 't-1');
expect(live).toMatchObject({
no: 'INS-7',
state: 'running',
reason: null,
endedAt: null,
durationMs: null,
llm: 'deepseek-v4-flash',
input: { content: '工业电源项目在用 LM2596S-5.0', source: { kind: 'manual', label: '手动启动 · 运营端' }, files: [{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 3 }] },
output: { files: [] },
});
expect(live?.steps.map((s) => [s.name, s.by, s.byName, s.res, s.durationMs, s.note])).toEqual([
['归一', 'agent', 'deepseek-v4-flash', 'done', 60_000, '把四项绑定'],
['交人确认', 'human', '柴浩', 'current', null, ''],
['放行', 'agent', 'deepseek-v4-flash', 'pending', null, ''],
]);
expect(live).not.toHaveProperty('input.output');
const redo = await ops.instanceDetail(W05, 't-redo');
expect(redo?.reason).toEqual({ kind: 'rejected', text: '验收驳回:证据不足 —— 负责人 柴浩' });
expect(redo?.steps.map((s) => s.res)).toEqual(['done', 'done', 'fail']);
const dead = await ops.instanceDetail(W05, 't-dead');
expect(dead).toMatchObject({ state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' }, endedAt: '2026-08-24T02:30:00.000Z', durationMs: 30 * 60_000, owner: { name: '冯兵' } });
const done = await ops.instanceDetail(W05, 't-done');
expect(done?.output.files).toEqual([{ index: 0, name: '报告.docx', contentType: expect.stringContaining('wordprocessingml'), size: 9 }]);
const mail = await ops.instanceDetail(W05, 't-mail');
expect(mail?.input.source).toEqual({ kind: 'mail', label: '邮件 · quotes@itelma.ru' });
expect(await ops.instanceDetail(W05, 'nope')).toBeUndefined();
});
it('上游工作流起的单标 upstream;测试台起的标 testbed', async () => {
const { ops } = harness({ rows: [row({ id: 't-wf', workflowId: 'wf-1', origin: null }), row({ id: 't-tb', origin: 'testbed', data: {} })] });
expect((await ops.instanceDetail(W05, 't-wf'))?.input.source).toEqual({ kind: 'upstream', label: '上游 · 统一报价 · Itelma' });
expect((await ops.instanceDetail(W05, 't-tb'))?.input.source).toEqual({ kind: 'testbed', label: '测试台试跑' });
});
it('文件:input 走上传件、output 走存档;越界 / 无单各回明确结果', async () => {
const { ops } = harness({ rows });
expect(await ops.instanceFile(W05, 't-1', 'input', 0)).toMatchObject({ kind: 'file', name: 'a.pdf', contentType: 'application/pdf' });
expect((await ops.instanceFile(W05, 't-1', 'input', 1)).kind).toBe('no-file');
expect(await ops.instanceFile(W05, 't-done', 'output', 0)).toMatchObject({ kind: 'file', name: '报告.docx' });
expect((await ops.instanceFile(W05, 't-1', 'output', 0)).kind).toBe('no-file');
expect((await ops.instanceFile(W05, 'nope', 'output', 0)).kind).toBe('no-instance');
});
it('终止:走 task.control,理由带人名;已终态 412;之后详情是 failed + terminated', async () => {
const { ops, controlled } = harness({ rows: [row({ id: 't-1' }), row({ id: 't-done', terminal: 'released', runtime: 'terminal' })] });
const done = await ops.terminate(W05, 't-1', ME);
expect(done).toMatchObject({ kind: 'ok', detail: { state: 'failed', reason: { kind: 'terminated', text: '由 冯兵 手动终止' } } });
expect(controlled).toEqual([{ taskId: 't-1', reason: '由 冯兵 手动终止' }]);
expect((await ops.terminate(W05, 't-done', ME)).kind).toBe('terminal');
expect((await ops.terminate(W05, 't-1', ME)).kind).toBe('terminal');
expect((await ops.terminate(W05, 'nope', ME)).kind).toBe('not-found');
});
});
@@ -0,0 +1,101 @@
/** L3 动作过程记录:计划项 × 运行态 × 引擎痕 */
import { describe, expect, it } from 'vitest';
import { parseCmmn } from '@modules/loop-engine';
import type { ItemRuntime } from '@modules/loop-engine';
import { followRes, stepsOf } from '../activity-ops/instance-steps.ts';
const XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL" id="d">
<case id="c" name="试">
<casePlanModel id="p" name="p">
<humanTask id="S1" name="归一"/>
<humanTask id="S2" name="交人确认">
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<humanTask id="S3" name="放行"/>
<milestone id="M1" name="里程碑"/>
</casePlanModel>
</case>
</definitions>`;
const rt = (states: Record<string, ItemRuntime['state']>): ItemRuntime[] => Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
const line = (title: string, at: string) => ({ kind: 'engine-transition', title, at });
const intents = new Map([['S1', '把来件归一成台账行'], ['S2', '派一张执行单交人']]);
describe('stepsOf', () => {
const items = parseCmmn(XML).items;
it('只摆任务项(里程碑不算),按文档序;人工激活的项是人办的;时刻与用时来自引擎痕', () => {
const steps = stepsOf({
items,
runtimeItems: rt({ S1: 'Completed', S2: 'Active', S3: 'Available' }),
intents,
history: [
line('开始办:归一', '2026-08-27T02:00:00.000Z'),
line('完成:归一', '2026-08-27T02:03:00.000Z'),
line('开始办:交人确认', '2026-08-27T02:03:30.000Z'),
],
state: 'running',
ownerName: '柴浩',
llm: 'deepseek-v4-flash',
});
expect(steps.map((s) => [s.seq, s.name, s.by, s.byName, s.res])).toEqual([
[1, '归一', 'agent', 'deepseek-v4-flash', 'done'],
[2, '交人确认', 'human', '柴浩', 'current'],
[3, '放行', 'agent', 'deepseek-v4-flash', 'pending'],
]);
expect(steps[0]).toMatchObject({ at: '2026-08-27T02:00:00.000Z', durationMs: 180_000, note: '把来件归一成台账行' });
expect(steps[1]).toMatchObject({ at: '2026-08-27T02:03:30.000Z', durationMs: null, note: '派一张执行单交人' });
expect(steps[2]).toMatchObject({ at: null, durationMs: null, note: '' });
});
it('自动收步的项拿收步那一刻当时刻;没有 runtime 的项是 pending', () => {
const steps = stepsOf({
items,
runtimeItems: rt({ S1: 'Completed' }),
intents,
history: [line('自动收步:S1 的成果已在台账里(rows > 0)—— 不必再问模型', '2026-08-27T02:01:00.000Z')],
state: 'running',
ownerName: '柴浩',
llm: 'm',
});
expect(steps[0]).toMatchObject({ res: 'done', at: '2026-08-27T02:01:00.000Z', durationMs: null });
expect(steps[1]?.res).toBe('pending');
});
it('终止 / 失败:正在办的那一步记 fail;都没开就记第一个没到的;驳回记最后一个已完成的', () => {
const facts = { items, intents, history: [], ownerName: '柴浩', llm: 'm' };
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Active' }), state: 'failed' }).map((s) => s.res)).toEqual(['done', 'fail', 'pending']);
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Terminated', S2: 'Terminated' }), state: 'failed' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Completed', S2: 'Completed', S3: 'Completed' }), state: 'rejected' }).map((s) => s.res)).toEqual(['done', 'done', 'fail']);
expect(stepsOf({ ...facts, runtimeItems: rt({ S1: 'Failed' }), state: 'running' }).map((s) => s.res)).toEqual(['fail', 'pending', 'pending']);
});
it('重复实例化的项看最新那一实例', () => {
const steps = stepsOf({
items,
runtimeItems: [{ id: 'S2', state: 'Completed', instance: 1 }, { id: 'S2', state: 'Active', instance: 2 }],
intents,
history: [],
state: 'running',
ownerName: '柴浩',
llm: 'm',
});
expect(steps[1]?.res).toBe('current');
});
});
describe('followRes:第 9 步跟第 8 步(放行那一步没有自己的收步事件)', () => {
const step = (seq: number, res: 'done' | 'current' | 'pending' | 'fail') => ({ seq, name: `S${seq}`, by: 'agent' as const, byName: 'llm', at: null, durationMs: null, res, note: '' });
it('已放行的单:第 8 步 done → 第 9 步也 done、时刻跟第 8 步;别的字段不动', () => {
const steps = [...Array.from({ length: 7 }, (_, i) => step(i + 1, 'done')), { ...step(8, 'done'), at: '2026-08-27T14:30:26.080Z' }, { ...step(9, 'pending'), by: 'human' as const, byName: '柴浩' }];
const out = followRes(steps, { seq: 9, from: 8 });
expect(out[8]).toEqual({ ...step(9, 'done'), at: '2026-08-27T14:30:26.080Z', by: 'human', byName: '柴浩' });
expect(out.slice(0, 8)).toEqual(steps.slice(0, 8));
});
it('第 8 步 fail / pending 时第 9 步照样跟;步数不够 9 的活动什么都不动', () => {
expect(followRes([step(1, 'done'), ...Array.from({ length: 7 }, (_, i) => step(i + 2, 'pending')), step(9, 'pending')].map((s) => (s.seq === 8 ? { ...s, res: 'fail' as const } : s)), { seq: 9, from: 8 })[8]?.res).toBe('fail');
const seven = Array.from({ length: 7 }, (_, i) => step(i + 1, 'done'));
expect(followRes(seven, { seq: 9, from: 8 })).toEqual(seven);
});
});
@@ -0,0 +1,93 @@
/**
* 启动幂等键在真库上的并发:两条连接同时占同一枚键,**恰一个赢**;单没建成的过期键可被重占。
* 独占一个库(照 mail-store.it 的先例):建表 / 清表都在 `jcai_it_activity_ops` 里,谁的库都不碰。
*/
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { sql } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import { createActivityOpsStore } from '../activity-ops/store.ts';
const IT_DB = 'jcai_it_activity_ops';
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
try {
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
await admin.end();
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
} catch {
/* 开发实例连不上,试 Testcontainers */
}
try {
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
} catch (error) {
process.stderr.write(`⚠️ activity-ops store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`);
return undefined;
}
}
const database = await provisionDatabase();
let poolA: Pool | undefined;
let poolB: Pool | undefined;
let dbA: Db;
let dbB: Db;
const MIGRATION = new URL('../../../../../db/migrations/0035_activity_ops.sql', import.meta.url);
beforeAll(async () => {
if (database === undefined) return;
poolA = new Pool({ connectionString: database.url, max: 2 });
poolB = new Pool({ connectionString: database.url, max: 2 });
dbA = drizzle(poolA);
dbB = drizzle(poolB);
await poolA.query('create schema if not exists sys');
await poolA.query('drop table if exists sys.activity_launch; drop table if exists sys.activity_profile');
await poolA.query(readFileSync(MIGRATION, 'utf8'));
});
afterAll(async () => {
await poolA?.end();
await poolB?.end();
await database?.stop();
});
describe.skipIf(database === undefined)('activity_launch 幂等键(真库)', () => {
it('两条连接同时占同一枚键,恰一个 claimed,另一个读回那一行;绑定后重放拿到 task_id', async () => {
const a = createActivityOpsStore(dbA);
const b = createActivityOpsStore(dbB);
const input = { key: 'race-1', activityId: 'w05', ownerId: 'owner', launchedBy: 'me' };
const results = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 === 0 ? a : b).claimLaunch(input)));
expect(results.filter((r) => r === 'claimed')).toHaveLength(1);
for (const r of results) if (r !== 'claimed') expect(r).toMatchObject({ key: 'race-1', activityId: 'w05', taskId: null });
await a.bindLaunch('race-1', 't-1');
expect(await b.claimLaunch(input)).toMatchObject({ taskId: 't-1' });
expect(await b.launchOf('t-1')).toMatchObject({ key: 'race-1', launchedBy: 'me' });
});
it('单没建成的键:没过期不许重占;过期后恰一个重占成功;已绑 task 的永远不许', async () => {
const a = createActivityOpsStore(dbA);
const b = createActivityOpsStore(dbB);
expect(await a.claimLaunch({ key: 'stale-1', activityId: 'w05', ownerId: 'o', launchedBy: 'x' })).toBe('claimed');
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'y')).toBe(false);
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
const won = await Promise.all([a.reclaimLaunch('stale-1', 60_000, 'o', 'y'), b.reclaimLaunch('stale-1', 60_000, 'o', 'z')]);
expect(won.filter(Boolean)).toHaveLength(1);
await a.bindLaunch('stale-1', 't-2');
await dbA.execute(sql`update sys.activity_launch set claimed_at = now() - interval '2 minutes' where key = 'stale-1'`);
expect(await b.reclaimLaunch('stale-1', 60_000, 'o', 'q')).toBe(false);
});
it('活动档案:upsert 冲突即更新,负责人可空', async () => {
const a = createActivityOpsStore(dbA);
await a.upsertProfile({ activityId: 'w05', ownerId: null, description: '一', ico: 'git-compare', hue: 'teal' });
await a.upsertProfile({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
expect((await a.profilesOf()).get('w05')).toEqual({ activityId: 'w05', ownerId: 'owner', description: '二', ico: 'git-compare', hue: 'teal' });
});
});
@@ -0,0 +1,124 @@
/**
* 活动模块的 app 层存取:SQL 不求值,只看「几笔、带了什么」与行 → 视图的映射(时间戳归一、seq → 实例号)。
* 真库上的并发抢键在 `activity-ops-store.it.test.ts`。
*/
import { describe, expect, it } from 'vitest';
import { createActivityOpsStore } from '../activity-ops/store.ts';
type Fake = { calls: string[]; execute: (query: unknown) => Promise<{ rows: unknown[] }> };
/** 每次 execute 按顺序吐一份 rows;SQL 片段拼成字符串留着断言 */
function fakeDb(...results: unknown[][]): Fake {
const calls: string[] = [];
const queue = [...results];
return {
calls,
execute: async (query: unknown) => {
const q = query as { queryChunks?: unknown[] };
calls.push(JSON.stringify(q.queryChunks ?? query));
return { rows: queue.shift() ?? [] };
},
};
}
const RAW_TASK = {
id: 't-1',
seq: '17',
activity_id: 'part-substitution-assessment',
workflow_id: null,
origin: 'ops',
owner_id: 's-1',
title: 'W05',
runtime: 'running',
terminal: null,
reason: null,
envelope: { packageVersion: '1.5.0' },
input_data: { customerCode: 'CU-0142' },
used_steps: '3',
// pg 的 timestamptz 在 raw execute 这一路回文本(实测),也可能是 Date —— 两种都要归一成 ISO
started_at: '2026-08-27 16:55:05.123+08',
finished_at: new Date('2026-08-27T08:55:14.383Z'),
};
describe('createActivityOpsStore', () => {
it('listTasks:seq → INS-n、时间戳归一成 ISO、jsonb 取成对象、数字列转数', async () => {
const db = fakeDb([RAW_TASK]);
const rows = await createActivityOpsStore(db).listTasks('part-substitution-assessment');
expect(rows).toEqual([
{
id: 't-1',
no: 'INS-17',
activityId: 'part-substitution-assessment',
workflowId: null,
origin: 'ops',
ownerId: 's-1',
title: 'W05',
runtime: 'running',
terminal: null,
reason: null,
envelope: { packageVersion: '1.5.0' },
data: { customerCode: 'CU-0142' },
usedSteps: 3,
startedAt: '2026-08-27T08:55:05.123Z',
finishedAt: '2026-08-27T08:55:14.383Z',
},
]);
expect(db.calls[0]).toContain('order by started_at desc, seq desc');
expect(db.calls[0]).toContain('activity_id = ');
});
it('latestReleased 只看已放行的、按放行时刻倒序取一条;没有回 undefined', async () => {
const db = fakeDb([RAW_TASK]);
const one = await createActivityOpsStore(db).latestReleased('part-substitution-assessment');
expect(one?.no).toBe('INS-17');
expect(db.calls[0]).toContain("terminal = 'released'");
expect(db.calls[0]).toContain('order by finished_at desc nulls last, started_at desc limit 1');
expect(await createActivityOpsStore(fakeDb([])).latestReleased('x')).toBeUndefined();
});
it('getTask 认活动 + 任务两把钥匙;没命中回 undefined', async () => {
const db = fakeDb([]);
expect(await createActivityOpsStore(db).getTask('a', 't')).toBeUndefined();
expect(db.calls[0]).toContain('activity_id = ');
expect(db.calls[0]).toContain('and id = ');
});
it('countByActivity 按活动数;profilesOf 按活动键映射', async () => {
const db = fakeDb([{ activity_id: 'a', n: '2' }, { activity_id: 'b', n: 1 }], [{ activity_id: 'a', owner_id: null, description: null, ico: 'target', hue: 'blue' }]);
const store = createActivityOpsStore(db);
expect([...(await store.countByActivity()).entries()]).toEqual([['a', 2], ['b', 1]]);
const profiles = await store.profilesOf();
expect(profiles.get('a')).toEqual({ activityId: 'a', ownerId: null, description: '', ico: 'target', hue: 'blue' });
});
it('claimLaunch:一句 SQL 判 + 占(on conflict do nothing returning);插不进去就读回那一行', async () => {
const claimed = fakeDb([{ key: 'k' }]);
expect(await createActivityOpsStore(claimed).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toBe('claimed');
expect(claimed.calls[0]).toContain('on conflict (key) do nothing');
expect(claimed.calls[0]).toContain('returning key');
expect(claimed.calls).toHaveLength(1);
const taken = fakeDb([], [{ key: 'k', activity_id: 'a', owner_id: 'o', launched_by: 'x', task_id: 't-9', claimed_at: new Date('2026-08-27T08:00:00.000Z') }]);
expect(await createActivityOpsStore(taken).claimLaunch({ key: 'k', activityId: 'a', ownerId: 'o', launchedBy: 'me' })).toEqual({
key: 'k',
activityId: 'a',
ownerId: 'o',
launchedBy: 'x',
taskId: 't-9',
claimedAt: '2026-08-27T08:00:00.000Z',
});
expect(taken.calls).toHaveLength(2);
});
it('reclaimLaunch 只占「单没建成且已过期」的键;bindLaunch 落 task_id;upsertProfile 冲突即更新', async () => {
const db = fakeDb([{ key: 'k' }], [], []);
const store = createActivityOpsStore(db);
expect(await store.reclaimLaunch('k', 60_000, 'o', 'me')).toBe(true);
expect(db.calls[0]).toContain('task_id is null');
expect(db.calls[0]).toContain("milliseconds')::interval");
await store.bindLaunch('k', 't-1');
expect(db.calls[1]).toContain('set task_id = ');
await store.upsertProfile({ activityId: 'a', ownerId: null, description: 'd', ico: null, hue: null });
expect(db.calls[2]).toContain('on conflict (activity_id) do update');
});
});
@@ -0,0 +1,76 @@
/** 清单一句话与检索 */
import { describe, expect, it } from 'vitest';
import type { InstanceSummary } from '@contracts';
import { staffIdSchema } from '@contracts';
import { fieldSpecsOf } from '../activity-ops/launch-params.ts';
import { applyInstanceFilters, clip, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from '../activity-ops/summaries.ts';
const specs = fieldSpecsOf({
properties: {
customerCode: { type: 'string' },
requestDescription: { type: 'string', 'x-control': 'textarea' },
datasheets: { type: 'array', format: 'file' },
},
});
describe('inputSummaryOf / inputFilesOf / outputSummaryOf', () => {
it('textarea 那格优先;没有就最长的字符串;再没有退回来件主题;文件字段不算', () => {
expect(inputSummaryOf({ customerCode: 'CU-0142', requestDescription: ' 工业电源项目\n在用 LM2596S-5.0 ' }, specs)).toBe('工业电源项目 在用 LM2596S-5.0');
expect(inputSummaryOf({ customerCode: 'CU-0142', datasheets: [{ ref: 'r', name: 'a-very-long-file-name.pdf' }] }, specs)).toBe('CU-0142');
expect(inputSummaryOf({}, specs, 'RE: 询价')).toBe('RE: 询价');
expect(inputSummaryOf({}, specs)).toBe('');
expect(clip('x'.repeat(100))).toHaveLength(80);
expect(clip('x'.repeat(100)).endsWith('…')).toBe(true);
});
it('文件只认带 ref 的;index 跨字段连续', () => {
const files = inputFilesOf({ datasheets: [{ name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }, { name: 'no-ref' }, 'junk'] }, specs);
expect(files).toEqual([{ index: 0, name: 'a.pdf', contentType: 'application/pdf', size: 10, ref: 'u-1' }]);
});
it('产出:有文件摆文件名;没有摆发口;都没有 null', () => {
expect(outputSummaryOf(['报告.docx', '对照表.xlsx'], ['x'])).toBe('报告.docx、对照表.xlsx');
expect(outputSummaryOf([], ['assessment-released'])).toBe('已发口:assessment-released');
expect(outputSummaryOf([], [])).toBeNull();
});
});
const owner = { id: 's-1', name: '柴浩' };
const mk = (id: string, no: string, state: InstanceSummary['state'], startedAt: string, ownerId = 's-1'): InstanceSummary => ({
id,
no,
state,
owner: { id: staffIdSchema.parse(ownerId), name: owner.name },
input: 'x',
output: null,
startedAt,
});
const items = [
mk('t-aaa', 'INS-24', 'running', '2026-08-25T02:12:00.000Z'),
mk('t-bbb', 'INS-17', 'completed', '2026-08-24T09:03:00.000Z', 's-2'),
mk('t-ccc', 'INS-9', 'failed', '2026-08-20T01:00:00.000Z'),
];
describe('applyInstanceFilters / statsOf / pageOf', () => {
it('实例号模糊、负责人精确、状态精确、日期段含两端整日(本地时区)', () => {
expect(applyInstanceFilters(items, { id: 'ins-2' }).map((i) => i.no)).toEqual(['INS-24']);
expect(applyInstanceFilters(items, { id: 'bbb' }).map((i) => i.no)).toEqual(['INS-17']);
expect(applyInstanceFilters(items, { ownerId: 's-2' }).map((i) => i.no)).toEqual(['INS-17']);
expect(applyInstanceFilters(items, { state: 'failed' }).map((i) => i.no)).toEqual(['INS-9']);
const day = (iso: string) => {
const d = new Date(iso);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z'), startedTo: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-17']);
expect(applyInstanceFilters(items, { startedFrom: day('2026-08-24T09:03:00.000Z') }).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
expect(applyInstanceFilters(items, { startedTo: day('2026-08-20T01:00:00.000Z') }).map((i) => i.no)).toEqual(['INS-9']);
expect(applyInstanceFilters(items, {})).toHaveLength(3);
});
it('统计五键齐;分页越界给空', () => {
expect(statsOf(items)).toEqual({ total: 3, byState: { pending: 0, running: 1, completed: 1, rejected: 0, failed: 1 } });
expect(pageOf(items, 1, 2).map((i) => i.no)).toEqual(['INS-24', 'INS-17']);
expect(pageOf(items, 2, 2).map((i) => i.no)).toEqual(['INS-9']);
expect(pageOf(items, 3, 2)).toEqual([]);
});
});
@@ -0,0 +1,18 @@
/** 沙箱视图出口:模版原文不改,只在 </body> 前注入报高脚本 */
import { describe, expect, it } from 'vitest';
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
import { injectHeightScript } from '../activity-ops/view-html.ts';
describe('injectHeightScript', () => {
it('插在 </body> 之前;没有 body 就追加;原文一字不改', () => {
const html = '<!doctype html><html><body><h1>一览</h1></body></html>';
const out = injectHeightScript(html);
expect(out.startsWith('<!doctype html><html><body><h1>一览</h1>')).toBe(true);
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('</body>'));
expect(out).toContain(ACTIVITY_VIEW_HEIGHT_MESSAGE);
expect(out).toContain("postMessage({type:T,height:h()},'*')");
const bare = injectHeightScript('<p>x</p>');
expect(bare.startsWith('<p>x</p>')).toBe(true);
expect(bare).toContain('<script>');
});
});
@@ -0,0 +1,181 @@
/**
* 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 IO,读表用一张内存表替。
*/
import { describe, expect, it } from 'vitest';
import type { ViewData } from '@contracts';
import { caseStamp, openedAtText } from '../case-stamp.ts';
import { resolveViewData, viewScopeOf, slugOf, localMinute } from '../activity-ops/view-scope.ts';
import type { ViewRead, ViewRow } from '../activity-ops/view-scope.ts';
const SPEC: ViewData = {
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
rows: [
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }] },
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: 'sub_eval.verdict.request_id' } },
{
as: 'validation.history',
ledger: 'validation.record',
by: { column: 'validation_internal_pn', from: 'sub_eval.pn_check.internal_pn' },
not: { column: 'request_id', from: 'sub_eval.verdict.request_id' },
},
],
};
const TABLES: Record<string, ViewRow[]> = {
'sub_eval.verdict': [
{ eval_ref: 'W05-20260827215103', request_id: 'REQ-20260827215103', original_internal_pn: 'LM2596S-5.0' },
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000', original_internal_pn: 'X' },
],
'sub_eval.pn_check': [
{ eval_ref: 'W05-20260827215103', internal_pn: 'XL2596S-5.0', role: 'candidate', exists_status: 'created' },
{ eval_ref: 'W05-20260827215103', internal_pn: 'LM2596S-5.0', role: 'original', exists_status: 'found' },
{ eval_ref: 'W05-20260827000000', internal_pn: 'X', role: 'original', exists_status: 'found' },
],
'parts.profile': [
{ internal_pn: 'LM2596S-5.0', brand_name: 'TI', lifecycle_status: 'nrnd' },
{ internal_pn: 'XL2596S-5.0', brand_name: 'XLSEMI', lifecycle_status: 'active' },
{ internal_pn: 'X', brand_name: '?', lifecycle_status: 'active' },
],
'substitution.relation': [
{ substitution_relation_id: 'REL-1', eval_ref: 'W05-20260827215103', recommendation_level: 'conditional' },
{ substitution_relation_id: 'REL-9', eval_ref: 'W05-20260827000000', recommendation_level: 'alternate' },
],
'validation.record': [
{ validation_id: 'VAL-A', request_id: 'REQ-20260827215103', validation_internal_pn: 'XL2596S-5.0' },
{ validation_id: 'VAL-OLD', request_id: 'REQ-20260101000000', validation_internal_pn: 'XL2596S-5.0' },
{ validation_id: 'VAL-OTHER', request_id: 'REQ-20260101000001', validation_internal_pn: 'Z' },
],
};
/** 内存表:等值过滤 + 单列排序;记下每一次读 */
function reader(tables: Record<string, ViewRow[]>, broken: ReadonlySet<string> = new Set()): { read: ViewRead; calls: string[] } {
const calls: string[] = [];
const read: ViewRead = async (address, where, orderBy) => {
const name = `${address.domain}.${address.object}`;
calls.push(`${name} ${JSON.stringify(where)}`);
if (broken.has(name)) return undefined;
const rows = (tables[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => String(r[k]) === v));
const order = orderBy[0];
if (order === undefined) return rows;
return [...rows].sort((a, b) => String(a[order.field]).localeCompare(String(b[order.field])) * (order.dir === 'asc' ? 1 : -1));
};
return { read, calls };
}
const DECLARED = [
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
{ domain: 'substitution', object: 'relation', fields: [{ name: 'recommendation_level', tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }] },
];
describe('caseStamp / openedAtText', () => {
it('同一串:文本带分隔符、案号只留 14 位数字;认不出的时刻两者都是空串', () => {
const iso = new Date(2026, 7, 27, 21, 51, 3).toISOString();
expect(openedAtText(iso)).toBe('2026-08-27 21:51:03');
expect(caseStamp(iso)).toBe('20260827215103');
expect(openedAtText('not-a-date')).toBe('');
expect(caseStamp('')).toBe('');
});
});
describe('resolveViewData', () => {
it('按序链式求值:案号 → 核对表 → 料号圈主数据;行序随 from 值的顺序(原 PN 排第一)', async () => {
const { read, calls } = reader(TABLES);
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
expect(out.caseFound).toBe(true);
expect(out.skipped).toEqual([]);
expect(out.sets['sub_eval.verdict']).toHaveLength(1);
expect(out.sets['sub_eval.pn_check']?.map((r) => r['internal_pn'])).toEqual(['LM2596S-5.0', 'XL2596S-5.0']);
expect(out.sets['parts.profile']?.map((r) => r['brand_name'])).toEqual(['TI', 'XLSEMI']);
expect(out.sets['substitution.relation']?.map((r) => r['substitution_relation_id'])).toEqual(['REL-1']);
// 每个值单独一次等值读:别单的行一条都不进来
expect(calls).toContain('parts.profile {"internal_pn":"LM2596S-5.0"}');
expect(calls).toContain('parts.profile {"internal_pn":"XL2596S-5.0"}');
expect(calls.filter((c) => c.startsWith('parts.profile'))).toHaveLength(2);
});
it('not 排除:同料号的历史验证记录不含本单挂的那条;别的料号不进来', async () => {
const { read } = reader(TABLES);
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
expect(out.sets['validation.record']?.map((r) => r['validation_id'])).toEqual(['VAL-A']);
expect(out.sets['validation.history']?.map((r) => r['validation_id'])).toEqual(['VAL-OLD']);
});
it('案号查无 → caseFound=false,下游集合全空、一次多余的读都不发', async () => {
const { read, calls } = reader(TABLES);
const out = await resolveViewData(SPEC, { caseRef: 'W05-19990101000000', read });
expect(out.caseFound).toBe(false);
expect(out.sets['parts.profile']).toEqual([]);
expect(calls.some((c) => c.startsWith('parts.profile'))).toBe(false);
});
it('某张表读不了:那个集合为空并点名,别的集合照常', async () => {
const { read } = reader(TABLES, new Set(['parts.profile']));
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
expect(out.sets['parts.profile']).toEqual([]);
expect(out.sets['substitution.relation']).toHaveLength(1);
expect(out.skipped).toEqual(['parts.profile']);
});
it('from 指向没定义过的集合 → 点名、集合为空(声明写错不炸)', async () => {
const { read } = reader(TABLES);
const spec: ViewData = { ...SPEC, rows: [{ as: 'x', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'nope.internal_pn' } }] };
const out = await resolveViewData(spec, { caseRef: 'W05-20260827215103', read });
expect(out.sets['x']).toEqual([]);
expect(out.skipped).toEqual(['x']);
});
});
describe('viewScopeOf', () => {
it('行数组 / 首行标量 / 行数 / tally 计数(先铺 0)/ 实例事实 / 三态', () => {
const sets = {
'parts.profile': [
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
],
'substitution.relation': [],
};
const scope = viewScopeOf({
spec: SPEC,
sets,
declared: DECLARED,
instance: { no: 'INS-20', startedAt: new Date(2026, 7, 27, 21, 51, 3).toISOString(), finishedAt: new Date(2026, 7, 27, 21, 57, 56).toISOString(), owner: '柴浩' },
caseRef: 'W05-20260827215103',
state: 'ready',
});
expect(scope['board:state']).toBe('ready');
expect(scope['case:instanceNo']).toBe('INS-20');
expect(scope['case:openedAt']).toBe('2026-08-27 21:51');
expect(scope['case:releasedAt']).toBe('2026-08-27 21:57');
expect(scope['case:owner']).toBe('柴浩');
expect(scope['case:ref']).toBe('W05-20260827215103');
expect(scope['parts.profile']).toBe(sets['parts.profile']);
expect(scope['parts.profile.internal_pn']).toBe('LM2596S-5.0');
expect(scope['parts_profile_rows']).toBe(2);
expect(scope['parts_profile_lifecycle_status_nrnd']).toBe(1);
expect(scope['parts_profile_lifecycle_status_active']).toBe(1);
expect(scope['parts_profile_lifecycle_status_eol']).toBe(0);
expect(scope['substitution_relation_rows']).toBe(0);
expect(scope['substitution_relation_recommendation_level_not_recommended']).toBe(0);
// 声明里没求出来的集合也铺空数组:模版的 data-rows 不会对着 undefined
expect(scope['sub_eval.verdict']).toEqual([]);
});
it('没有已放行实例:none 态,实例事实全空串', () => {
const scope = viewScopeOf({ spec: SPEC, sets: {}, declared: DECLARED, state: 'none' });
expect(scope['board:state']).toBe('none');
expect(scope['case:instanceNo']).toBe('');
expect(scope['case:releasedAt']).toBe('');
expect(scope['parts_profile_rows']).toBe(0);
});
it('slug 与时刻', () => {
expect(slugOf('sub_eval.verdict')).toBe('sub_eval_verdict');
expect(slugOf('validation.history')).toBe('validation_history');
expect(slugOf('not_recommended')).toBe('not_recommended');
expect(localMinute(null)).toBe('');
expect(localMinute('garbage')).toBe('');
});
});
@@ -0,0 +1,26 @@
/**
* 活动模块路由的归属:11 条全部进契约、注册表建得起来(与设计端的 activity-packages 互不撞),全归运营中心。
*/
import { describe, expect, it } from 'vitest';
import * as contracts from '@contracts';
import { ACTIVITY_OPS_ROUTES } from '@contracts';
import { buildRouteRegistry, routesOf } from '../route-registry.ts';
describe('活动模块路由契约', () => {
it('11 条全在契约命名空间里,注册表能建起来', () => {
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
expect(ACTIVITY_OPS_ROUTES).toHaveLength(11);
for (const route of ACTIVITY_OPS_ROUTES) expect(registry.has(route.method, route.path), route.path).toBe(true);
});
it('全归 ops;设计端活动库让位到 activity-packages 后仍归 design', () => {
const registry = buildRouteRegistry(routesOf(Object.values(contracts)));
for (const route of ACTIVITY_OPS_ROUTES) expect(route.access).toBe('ops');
expect(registry.lookup('GET', '/api/gov/activities')).toBe('ops');
expect(registry.lookup('GET', '/api/gov/activity-packages')).toBe('design');
expect(registry.lookup('POST', '/api/gov/activity-packages')).toBe('design');
expect(registry.lookup('GET', '/api/gov/activities/part-substitution-assessment/view')).toBe('ops');
expect(registry.lookup('POST', '/api/gov/activities/part-substitution-assessment/instances/t-1/terminate')).toBe('ops');
expect(registry.lookup('GET', '/api/gov/activities/x/instances/t-1/files/output/0')).toBe('ops');
});
});
@@ -0,0 +1,150 @@
/**
* 活动模块路由:zod 校验 → 用例 → 结果翻状态码。用例是替身(用例本身另有测试),不挂鉴权中间件,
* 身份由测试自己塞进上下文(终止 / 启动要知道是谁按的)。
*/
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import type { InstanceDetail, Principal } from '@contracts';
import { positionIdSchema, staffIdSchema } from '@contracts';
import type { AppEnv } from '../http-env.ts';
import { registerActivityRoutes } from '../routes/activities.ts';
import type { ActivityOps } from '../activity-ops/service.ts';
const W05 = 'part-substitution-assessment';
const owner = { id: staffIdSchema.parse('s-1'), name: '柴浩' };
const detail: InstanceDetail = {
id: 't-1',
no: 'INS-7',
state: 'running',
owner,
output: { files: [] },
startedAt: '2026-08-27T02:00:00.000Z',
activityId: W05,
llm: 'deepseek-v4-flash',
endedAt: null,
durationMs: null,
reason: null,
input: { content: 'x', source: { kind: 'manual', label: '手动启动 · 柴浩' }, submittedAt: '2026-08-27T02:00:00.000Z', files: [] },
steps: [],
};
const activity = { id: W05, name: 'W05', biz: '需求分析', status: 'active' as const, version: '1.5.0', owner, llm: 'm', createdAt: 'c', desc: '', view: null };
function fakeOps(over: Partial<ActivityOps> = {}): ActivityOps {
return {
listActivities: async () => [{ ...activity, instanceCount: 1 }],
getActivity: async (id) => (id === W05 ? activity : undefined),
viewHtml: async (id) => (id === W05 ? { kind: 'html', html: '<html><body>一览<script>x</script></body></html>' } : { kind: 'no-view' }),
listInstances: async (id, q) => (id === W05 ? { stats: { total: 1, byState: { pending: 0, running: 1, completed: 0, rejected: 0, failed: 0 } }, total: 1, page: q.page, size: q.size, items: [] } : undefined),
launchParams: async (id) => (id === W05 ? { activityId: id, version: '1.5.0', params: [] } : undefined),
launch: async (id, input) => {
if (id !== W05) return { kind: 'not-found' };
if (input.idempotencyKey === 'busy') return { kind: 'busy', message: '正在创建' };
if (input.idempotencyKey === 'bad') return { kind: 'invalid', issues: [{ key: 'customerCode', message: '必填' }] };
if (input.idempotencyKey === 'off') return { kind: 'disabled' };
if (input.idempotencyKey === 'owner') return { kind: 'owner-invalid', message: '责任人不在名册里' };
return { kind: 'ok', detail, created: true };
},
instanceDetail: async (id, iid) => (id === W05 && iid === 't-1' ? detail : undefined),
instanceFile: async (id, iid, io, index) =>
id === W05 && iid === 't-1' && io === 'input' && index === 0 ? { kind: 'file', name: '报 告.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2, 3]) } : iid === 't-1' ? { kind: 'no-file' } : { kind: 'no-instance' },
setEnabled: async (id, enabled) => (id === W05 ? { ...activity, status: enabled ? 'active' : 'disabled' } : undefined),
terminate: async (id, iid) => (id !== W05 || iid === 'nope' ? { kind: 'not-found' } : iid === 't-done' ? { kind: 'terminal' } : { kind: 'ok', detail: { ...detail, state: 'failed', reason: { kind: 'terminated', text: '由 柴浩 手动终止' } } }),
...over,
};
}
function harness(opts: { anonymous?: boolean } = {}) {
const app = new Hono<AppEnv>();
const principal: Principal = {
staff: { id: owner.id, account: 'chaihao', name: owner.name, positionId: positionIdSchema.parse('pm'), state: 'active', roles: [], kind: 'person' },
position: { id: positionIdSchema.parse('pm'), code: 'PM', name: '项目经理', note: '' },
roles: [],
ends: ['ops'],
};
if (!opts.anonymous) {
app.use('*', async (c, next) => {
c.set('principal', principal);
await next();
});
}
registerActivityRoutes(app, { activityOps: fakeOps() });
const json = (method: string, path: string, body?: unknown) =>
app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { app, json };
}
describe('活动路由', () => {
it('L1 / L2:清单按 status 过滤入参校验;详情 404;视图回 text/html', async () => {
const h = harness();
expect(await (await h.json('GET', '/api/gov/activities')).json()).toMatchObject({ activities: [{ id: W05, instanceCount: 1 }] });
expect((await h.json('GET', '/api/gov/activities?status=paused')).status).toBe(400);
expect((await h.json('GET', `/api/gov/activities/${W05}`)).status).toBe(200);
expect((await h.json('GET', '/api/gov/activities/nope')).status).toBe(404);
const view = await h.json('GET', `/api/gov/activities/${W05}/view`);
expect(view.status).toBe(200);
expect(view.headers.get('content-type')).toContain('text/html');
expect(view.headers.get('cache-control')).toBe('no-store');
expect(await view.text()).toContain('一览');
expect((await h.json('GET', '/api/gov/activities/nope/view')).status).toBe(404);
});
it('实例清单:查询默认值与上限;启动参数表 404', async () => {
const h = harness();
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances`)).json()).toMatchObject({ page: 1, size: 8 });
expect(await (await h.json('GET', `/api/gov/activities/${W05}/instances?page=2&size=20&state=running`)).json()).toMatchObject({ page: 2, size: 20 });
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?size=99`)).status).toBe(400);
expect((await h.json('GET', `/api/gov/activities/${W05}/instances?startedFrom=8-1`)).status).toBe(400);
expect((await h.json('GET', '/api/gov/activities/nope/instances')).status).toBe(404);
expect((await h.json('GET', `/api/gov/activities/${W05}/launch-params`)).status).toBe(200);
expect((await h.json('GET', '/api/gov/activities/nope/launch-params')).status).toBe(404);
});
it('启动:400 入参 / 412 禁用 / 409 在途 / 404 / 401 没身份 / 200 详情', async () => {
const h = harness();
const body = { idempotencyKey: 'k', ownerId: 's-1', params: { customerCode: 'CU-1' } };
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ownerId: 's-1' })).status).toBe(400);
const bad = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'bad' });
expect(bad.status).toBe(400);
expect(await bad.json()).toMatchObject({ code: 'validation-failed', detail: [{ key: 'customerCode' }] });
const owner = await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'owner' });
expect(owner.status).toBe(400);
expect(await owner.json()).toMatchObject({ detail: [{ key: 'ownerId' }] });
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'off' })).status).toBe(412);
expect((await h.json('POST', `/api/gov/activities/${W05}/instances`, { ...body, idempotencyKey: 'busy' })).status).toBe(409);
expect((await h.json('POST', '/api/gov/activities/nope/instances', body)).status).toBe(404);
const ok = await h.json('POST', `/api/gov/activities/${W05}/instances`, body);
expect(ok.status).toBe(200);
expect(await ok.json()).toMatchObject({ id: 't-1', no: 'INS-7' });
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances`, body)).status).toBe(401);
});
it('L3:详情 404;文件默认 JSON+base64、raw=1 直出字节带 RFC 5987 文件名;序号不合法 400', async () => {
const h = harness();
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1`)).status).toBe(200);
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope`)).status).toBe(404);
const json = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0`);
expect(await json.json()).toEqual({ name: '报 告.pdf', contentType: 'application/pdf', dataBase64: Buffer.from([1, 2, 3]).toString('base64') });
const raw = await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/0?raw=1`);
expect(raw.status).toBe(200);
expect(raw.headers.get('content-type')).toBe('application/pdf');
expect(raw.headers.get('content-disposition')).toBe(`attachment; filename*=UTF-8''${encodeURIComponent('报 告.pdf')}`);
expect(new Uint8Array(await raw.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/output/0`)).status).toBe(404);
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/nope/files/input/0`)).status).toBe(404);
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/both/0`)).status).toBe(400);
expect((await h.json('GET', `/api/gov/activities/${W05}/instances/t-1/files/input/x`)).status).toBe(400);
});
it('禁用 / 激活回活动详情;终止 412 已终态、404、401 没身份', async () => {
const h = harness();
expect(await (await h.json('POST', `/api/gov/activities/${W05}/disable`)).json()).toMatchObject({ status: 'disabled' });
expect(await (await h.json('POST', `/api/gov/activities/${W05}/activate`)).json()).toMatchObject({ status: 'active' });
expect((await h.json('POST', '/api/gov/activities/nope/disable')).status).toBe(404);
const done = await h.json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`);
expect(done.status).toBe(200);
expect(await done.json()).toMatchObject({ state: 'failed', reason: { kind: 'terminated' } });
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/t-done/terminate`)).status).toBe(412);
expect((await h.json('POST', `/api/gov/activities/${W05}/instances/nope/terminate`)).status).toBe(404);
expect((await harness({ anonymous: true }).json('POST', `/api/gov/activities/${W05}/instances/t-1/terminate`)).status).toBe(401);
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { taskIdSchema } from '@contracts';
import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts';
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } };
const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' };
describe('analyze 网关', () => {
it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => {
const seen: Array<Record<string, unknown>> = [];
const gateway = createAnalyzeGateway({
generate: (c: Record<string, unknown>) => {
seen.push(c);
return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
},
} as never);
const out = await gateway.run(call);
expect(out.object).toEqual({ items: [{ p: 'A' }] });
expect(seen[0]?.['system']).toBe('逐条判四态');
expect(String(seen[0]?.['prompt'])).toContain('"p": "A"');
expect(seen[0]?.['reasoning']).toBe('off');
});
it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => {
let n = 0;
const gateway = createAnalyzeGateway({
generate: () => {
n += 1;
return Promise.resolve(
n === 1
? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
: { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
);
},
} as never);
const out = await gateway.run(call);
expect(n).toBe(2);
expect(out.object).toEqual({ items: [] });
});
it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => {
let n = 0;
const gateway = createAnalyzeGateway({
generate: () => {
n += 1;
return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
},
} as never);
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
expect(n).toBe(2);
});
/*
* **两次调用的用量都要交回去记账**(2026-08-29 终审 C1)。
* `createModelGateway` 自己不做 `addConsumption` —— 不给 `onUsage`,配方里的 analyze
* 就是一条问模型不花钱的路,活动预算对它形同虚设。重问那一次照样烧真钱,一并记。
*/
it('用量交回 onUsage:第一次成的记一次,重问了记两次', async () => {
const billed: Array<{ taskId: string; tokens: number }> = [];
const once = createAnalyzeGateway(
{
generate: () =>
Promise.resolve({ text: '', object: { items: [] }, usage: { inputTokens: 30, outputTokens: 12, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
} as never,
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
);
await once.run(call);
expect(billed).toEqual([{ taskId: TASK, tokens: 42 }]);
billed.length = 0;
let n = 0;
const twice = createAnalyzeGateway(
{
generate: () => {
n += 1;
return Promise.resolve(
n === 1
? { text: 'not json', usage: { inputTokens: 5, outputTokens: 5, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
: { text: '', object: { items: [] }, usage: { inputTokens: 7, outputTokens: 3, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
);
},
} as never,
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
);
await twice.run(call);
expect(billed.map((b) => b.tokens)).toEqual([10, 10]);
});
it('两次都不成形也照样记两笔 —— 失败的调用不是免费的', async () => {
const billed: number[] = [];
const gateway = createAnalyzeGateway(
{
generate: () =>
Promise.resolve({ text: 'nope', usage: { inputTokens: 4, outputTokens: 6, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
} as never,
(_taskId, tokens) => { billed.push(tokens); return Promise.resolve(); },
);
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
expect(billed).toEqual([10, 10]);
});
});
@@ -0,0 +1,44 @@
/**
* **确认闸的条件档要在真发卡那条路上转交**(2026-09-01 用户实测:「两个芯片类型不一样,
* 页面上方有红色提示,右边派发的卡片还可以点击下一步」)。
*
* 病:发卡装配那段手写清单逐项点名转交 `gateLedgers`/`variant`,而 `gateWhen`/`gateWhenSay`
* 没被点名 —— 配方白纸黑字声明了「类型不一致不许确认」,落库的工单载荷里那两个字段是
* undefined(任务 43 法证)。红带是视图画的所以照亮;按钮禁用与服务端拒收读的都是
* 存下的 `order.gateWhen`,一起无声失效。**手写字段清单第四次丢值**(fields / blocks /
* redoable 之后),而且丢在 `variant` 上次被丢的同一段。
*
* 这个缝取的是模型也会路过的不可信卡骨架,逐项校验转交是设计 —— 所以治法不是整包 spread,
* 是把这两项的取用抽成纯函数、在 variant 的测试位旁边钉死。
*/
import { describe, expect, it } from 'vitest';
import { cardConditionGate } from '../wiring/engines.ts';
describe('cardConditionGate —— 条件档从卡骨架到工单载荷', () => {
it('声明了就转交,一个字不改', () => {
expect(cardConditionGate({ gateWhen: 'parts_profile_distinct_chip_type_id <= 1', gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
gateWhenSay: '两颗料不是同一个芯片类型',
});
});
it('没声明就什么都不带 —— 不发明键', () => {
expect(cardConditionGate({})).toEqual({});
expect(cardConditionGate({ gateLedgers: ['parts.profile'] })).toEqual({});
});
it('脏值不端下去:非字符串 / 空白串一律丢', () => {
expect(cardConditionGate({ gateWhen: 42, gateWhenSay: '话' })).toEqual({});
expect(cardConditionGate({ gateWhen: ' ', gateWhenSay: '话' })).toEqual({});
expect(cardConditionGate({ gateWhen: null })).toEqual({});
});
it('只有那句话没有条件 = 什么都不带(say 单独存在没有意义,评估端只认 when)', () => {
expect(cardConditionGate({ gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({});
});
it('条件在、话没写:条件照转交,拒回用兜底话术', () => {
expect(cardConditionGate({ gateWhen: 'a > 1' })).toEqual({ gateWhen: 'a > 1' });
expect(cardConditionGate({ gateWhen: 'a > 1', gateWhenSay: '' })).toEqual({ gateWhen: 'a > 1' });
});
});
@@ -0,0 +1,132 @@
/**
* 数据工单确认即入库(2026-08-27 用户定)—— 这段错了不报错:人按下确认、卡记成办结,
* 而库里一行没有;要到整单放行才发现,或者永远发现不了(试跑单不落主数据)。
*
* 这里测的是编排(纯逻辑 + 假台账):落哪几张、落成什么样留什么痕;真库那一侧在
* `ledger-store.it.test.ts`(范围放行三条)。
*/
import { describe, expect, it } from 'vitest';
import type { TaskId, WorkOrder } from '@contracts';
import { workOrderSchema } from '@contracts';
import type { ReleaseScope, ReleaseWriteResult } from '@modules/ledger';
import { dataOrderLedgers, landDataOrder } from '../data-order-landing.ts';
const T = 'task-1' as TaskId;
/** 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转 */
const order = (shape: Record<string, unknown>): WorkOrder =>
workOrderSchema.parse({
id: 'wo-1',
at: '2026-08-27T10:00:00Z',
title: '数据工单 · 芯片主数据创建(P-08):LM2596S-3.3',
basis: [{ k: '触发原因', v: '查无记录' }],
ask: '请建齐',
fields: [],
...shape,
});
const dataOrder = (extra: Record<string, unknown> = {}): WorkOrder =>
order({ mode: 'execution', variant: 'data', gateLedgers: ['parts.profile', 'parts.parameter'], ...extra });
describe('dataOrderLedgers —— 确认之后该入库的表', () => {
it('数据工单:与提交闸同源 —— 绑了 blocks 看绑的,否则看 gateLedgers;去重、去脏', () => {
expect(dataOrderLedgers(dataOrder())).toEqual(['parts.profile', 'parts.parameter']);
expect(dataOrderLedgers(dataOrder({ gateLedgers: [' parts.profile ', 'parts.profile', 'bad address', ''] }))).toEqual(['parts.profile']);
const bound = dataOrder({ blocks: [{ t: 'table', title: '表', cols: ['a'], rows: [], ledger: 'parts.document' }] });
expect(dataOrderLedgers(bound)).toEqual(['parts.document']);
});
it('普通执行工单 / 验收工单 / 没说自己建哪张表的数据工单 → 空(不替它猜,更不回落到必交付物)', () => {
expect(dataOrderLedgers(order({ mode: 'execution', gateLedgers: ['parts.profile'] }))).toEqual([]);
expect(dataOrderLedgers(dataOrder({ gateLedgers: undefined }))).toEqual([]);
expect(dataOrderLedgers(order({ mode: 'acceptance', criteria: [], actions: [], deliverable: 'x', gateLedgers: ['parts.profile'] }))).toEqual([]);
});
});
function harness(reply: ReleaseWriteResult | Error) {
const calls: Array<{ taskId: TaskId; scope: ReleaseScope | undefined }> = [];
const notes: Array<{ kind: string; title: string; detail: Record<string, unknown> }> = [];
let touched = 0;
const deps = {
ledger: {
releaseWrite: async (taskId: TaskId, scope?: ReleaseScope) => {
calls.push({ taskId, scope });
if (reply instanceof Error) throw reply;
return reply;
},
},
memory: {
append: async (entry: { kind: string; title: string; detail: Record<string, unknown> }) => {
notes.push({ kind: entry.kind, title: entry.title, detail: entry.detail });
},
},
labelOf: async (address: string) => ({ 'parts.profile': '芯片主数据', 'parts.parameter': '标准化参数集' })[address] ?? address,
touched: () => {
touched += 1;
},
};
return { deps, calls, notes, touched: () => touched };
}
const merged = (rows: Array<[string, string]>): ReleaseWriteResult => ({
mergedRows: rows.length,
emittedEvents: rows.length,
landedEarlierRows: 0,
simulatedRows: 0,
merged: rows.map(([object, pn]) => ({ domain: 'parts', object, key: { internal_pn: pn }, values: {} })),
});
describe('landDataOrder —— 确认落下之后', () => {
it('只并这张卡的那几张表(范围放行),归因带卡号;痕点名哪几张表各几行,并给模型指路;数据域跟着刷', async () => {
const h = harness(merged([['profile', 'A'], ['parameter', 'A'], ['parameter', 'A']]));
const got = await landDataOrder(h.deps, T, dataOrder());
expect(got.landed).toBe(true);
expect(h.calls).toEqual([
{ taskId: T, scope: { only: [{ domain: 'parts', object: 'profile' }, { domain: 'parts', object: 'parameter' }], cause: 'data-order:wo-1' } },
]);
expect(h.notes).toHaveLength(1);
expect(h.notes[0]?.kind).toBe('ledger-write');
expect(h.notes[0]?.title).toContain('芯片主数据 1 行、标准化参数集 2 行已进正式表');
expect(h.notes[0]?.title).toContain('别再往这几张表写它们');
expect(h.notes[0]?.detail).toMatchObject({
workOrderId: 'wo-1',
rows: 3,
tables: [
{ address: 'parts.profile', rows: 1 },
{ address: 'parts.parameter', rows: 2 },
],
});
expect(h.touched()).toBe(1);
});
it('试跑单挡下的主数据:痕如实说「只留在活动缓存」,数据域不刷', async () => {
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 0, simulatedRows: 3, merged: [] });
await landDataOrder(h.deps, T, dataOrder());
expect(h.notes[0]?.title).toContain('试跑');
expect(h.notes[0]?.title).toContain('未落正式表');
expect(h.touched()).toBe(0);
});
it('同一张卡确认两次:第二次无事发生,痕也这么说', async () => {
const h = harness({ mergedRows: 0, emittedEvents: 0, landedEarlierRows: 3, simulatedRows: 0, merged: [] });
await landDataOrder(h.deps, T, dataOrder());
expect(h.notes[0]?.title).toContain('早已入库');
});
it('不是数据工单就一个字不动(不调台账、不留痕)', async () => {
const h = harness(merged([]));
const got = await landDataOrder(h.deps, T, order({ mode: 'execution' }));
expect(got).toEqual({ landed: false, reason: 'not-data-order' });
expect(h.calls).toEqual([]);
expect(h.notes).toEqual([]);
});
it('入库失败不抛:人已确认的事实不倒退;原话留痕、行仍在草稿层等放行', async () => {
const h = harness(new Error('草稿与正式版并发冲突:1 处字段已被他单改动'));
const got = await landDataOrder(h.deps, T, dataOrder());
expect(got).toMatchObject({ landed: false, reason: 'failed' });
expect(h.notes[0]?.title).toContain('没能入库');
expect(h.notes[0]?.title).toContain('并发冲突');
expect(h.notes[0]?.title).toContain('放行时再并');
});
});
@@ -0,0 +1,114 @@
/**
* **决策卡的面孔与人工验收清单,兜底在装配层这一处**(2026-08-29 终审 C2)。
*
* 病:配方(`auto-runner`)发审核卡时只递了 `variant`,没递 `checklist` ——
* 而装配层从前只给 `variant` 兜底。于是那张卡穿着验收工单的衣服、`allowCustom` 也对,
* **一条待确认都没有**:回传门的 `checklistMissing` 在 checklist 缺席时恒过,
* 「确认通过」当场可按,2026-08-27 用户当面定的那道人工验收闸凭空没了。
* 屏上还看不出来 —— 清单区是空的,不是红的。
*/
import { describe, expect, it } from 'vitest';
import { checkChatCardReply } from '@modules/human-tool';
import { cardIdSchema } from '@contracts';
import type { ChatCard, ChatCardReply } from '@contracts';
import { decisionCardFace, needsStepLookup } from '../wiring/engines.ts';
const RELEASE_STEP = {
cardVariant: 'review' as const,
acceptanceChecklist: ['逐条核对差异明细', '确认推荐等级与依据一致'],
};
describe('决策卡的面孔与清单(装配层兜底)', () => {
it('调用方递了清单就用它的 —— 引擎按剧本定好递过来,兜底不抢', () => {
const face = decisionCardFace({ variant: 'review', checklist: ['我说了算'] }, RELEASE_STEP);
expect(face.checklist).toEqual(['我说了算']);
expect(face.reviewFace).toBe(true);
});
it('**配方那一路只递了 variant:清单退回剧本声明的**(C2 的正主)', () => {
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
expect(face.reviewFace).toBe(true);
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
});
it('面孔也照旧退回剧本(2026-08-27 那条不许回退)', () => {
const face = decisionCardFace({}, RELEASE_STEP);
expect(face.reviewFace).toBe(true);
});
it('剧本没声明清单:就是没有 —— 不发明一条', () => {
const face = decisionCardFace({ variant: 'review' }, { cardVariant: 'review' });
expect(face.checklist).toBeUndefined();
});
it('空清单当没给:退回剧本(空数组在回传门那儿等于没有闸)', () => {
const face = decisionCardFace({ variant: 'review', checklist: [] }, RELEASE_STEP);
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
});
/**
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审)。
*
* `isReleaseStep`(turn-runner.ts)认的形状是:一步声明了 `acceptanceChecklist`(给终局
* `AcceptForm` 用)而 `cardVariant` 不是 `review`(T_release 本身就是这种形状)。配方若在
* **这一步**发一张普通 `as:'decide'` 卡(没传 variant),从前这里照样把清单兜上去 ——
* 前端只在 `variant === 'review'` 时才渲染清单,人看不到勾可打,回传门却要求
* `checked ⊇ checklist`:卡按不下去。
*/
const NON_REVIEW_STEP_WITH_CHECKLIST = { acceptanceChecklist: ['逐条核对差异明细'] };
it('**非审核面孔的步声明了清单(T_release 那种形状),配方发普通决策卡:清单不兜上去**(I2 的二审正主)', () => {
const face = decisionCardFace({}, NON_REVIEW_STEP_WITH_CHECKLIST);
expect(face.reviewFace).toBe(false);
expect(face.checklist).toBeUndefined();
});
it('同一步:调用方明确递了 variant:review,清单才兜上去 —— reviewFace 是唯一的开关', () => {
const face = decisionCardFace({ variant: 'review' }, NON_REVIEW_STEP_WITH_CHECKLIST);
expect(face.reviewFace).toBe(true);
expect(face.checklist).toEqual(NON_REVIEW_STEP_WITH_CHECKLIST.acceptanceChecklist);
});
it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => {
expect(needsStepLookup({})).toBe(false);
expect(needsStepLookup({ itemId: 'T_release' })).toBe(true);
expect(needsStepLookup({ itemId: 'T_release', variant: 'review' })).toBe(true);
expect(needsStepLookup({ itemId: 'T_release', variant: 'review', checklist: ['a'] })).toBe(false);
});
});
/**
* **闸真的因此有牙** —— 上面挑出来的清单接到回传门上,「确认通过」才拦得住。
* 没有这一条,`decisionCardFace` 的断言只证明「函数回了个数组」。
*/
describe('清单缺席 = 闸恒过(这就是 C2 的后果)', () => {
const CARD_ID = cardIdSchema.parse('11111111-1111-4111-8111-111111111111');
const card = (checklist?: ReadonlyArray<string>): ChatCard => ({
k: 'decision',
id: CARD_ID,
at: '2026-08-29T00:00:00.000Z',
ask: '差异评估结论审核',
basis: [],
variant: 'review',
allowCustom: false,
options: [{ value: 'approve', label: '确认通过' }],
...(checklist === undefined ? {} : { checklist: [...checklist] }),
});
const reply: ChatCardReply = { k: 'decision', cardId: CARD_ID, value: 'approve' };
it('没有清单:一条都没点也通得过 —— 这正是配方发的那张卡从前的样子', () => {
expect(checkChatCardReply(card(), reply)).toEqual([]);
});
it('清单兜底进来之后:一条没点就拦', () => {
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
const violations = checkChatCardReply(card(face.checklist), reply);
expect(violations.map((v) => v.code)).toEqual(['checklist-incomplete']);
});
it('清单点全了才放行', () => {
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
const violations = checkChatCardReply(card(face.checklist), { ...reply, checked: [...RELEASE_STEP.acceptanceChecklist] });
expect(violations).toEqual([]);
});
});
@@ -0,0 +1,37 @@
/**
* **执行工单的面孔在装配层这一处定**(2026-08-31,用户实测:「你怎么给我发成了执行工单,
* 我需要的是验收工单」)。
*
* 病:配方递下来 `variant: 'accept'`(验收工单 —— 件只读、人只确认),而装配层这一处
* 是一句写死只认 `'data'` 的三目,别的值**静默丢掉** —— 对话区照旧摆「执行工单」。
*
* 更值得记的是**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,
* 前端那一头断言了「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样绿 ——
* 三处都对,而它们中间这一段把值扔了。**面孔要在真发卡那条路上断言。**
*/
import { describe, expect, it } from 'vitest';
import { executionCardVariant } from '../wiring/engines.ts';
describe('执行工单的两副变体面孔', () => {
it('data 数据工单 / accept 验收工单,两个都认', () => {
expect(executionCardVariant('data', undefined)).toBe('data');
expect(executionCardVariant('accept', undefined)).toBe('accept');
});
it('剧本声明优先于卡骨架 —— 模型抄卡会把 variant 抄丢(2026-08-27 实测)', () => {
expect(executionCardVariant(undefined, 'data')).toBe('data');
expect(executionCardVariant('data', 'accept')).toBe('accept');
});
it('`review` 是决策卡那一路的面孔,不许串到执行工单上', () => {
expect(executionCardVariant('review', undefined)).toBeUndefined();
expect(executionCardVariant(undefined, 'review')).toBeUndefined();
});
it('没写 / 认不出的值:普通执行工单,不把脏东西端下去', () => {
expect(executionCardVariant(undefined, undefined)).toBeUndefined();
expect(executionCardVariant('', undefined)).toBeUndefined();
expect(executionCardVariant({ variant: 'accept' }, undefined)).toBeUndefined();
expect(executionCardVariant(42, undefined)).toBeUndefined();
});
});
@@ -1,160 +1,26 @@
/**
* ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。
* 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵:
* A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/
* C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
* 【ACT-WORK-05 两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`
* (逐参数四态判定)与 `part-substitution-assessment.recommend`(推荐等级定级)曾经在
* `../tools/first-party.ts` 里跑,这份文件曾逐条钉死它们的比对语义(硬/软约束、范围
* 解析、客户约束边界、四档推荐等级……)。
*
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定改成
* `activities/part-substitution-assessment` 的 `case-model.ts` 里 `S2_assess.auto`
* 唯一一次 `analyze`(系统组装入参、模型只答判断),推荐等级改成同一步 `do` 链里的
* 投影按 spec §3.5 的 S-FEEL when 链现算 —— 规则只许有一处真源,这两枚工具与它们
* 对应的 D01/D02 两张 DMN 已经同批退役(见 `first-party.ts` 里留的墓志铭)。
*
* 这份文件不删(§12.5「不删测试」的铁律拦的是「删测试让门禁变绿」),但逐条比对
* 用例测的是已经不存在的代码,只能跟着它测的东西一起退场,缩成一条退役断言 ——
* 拦住的是「哪天有人手滑又把这两枚工具注册回去」。完整的历史用例见 git 历史。
*/
import { describe, expect, it } from 'vitest';
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';
const handler = (name: string) => {
const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
if (found === undefined) throw new Error(`没有工具 ${name}`);
return found[1];
};
/** ToolHandler 的入参形状见 `apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts`:纯工具不用连接目标 */
const call = async (name: string, input: Record<string, unknown>) => {
const out = await handler(name)({ input, connection: {}, operation: undefined, idempotencyKey: undefined });
if (!out.ok) throw new Error(`工具失败:${JSON.stringify(out)}`);
return out.output as Record<string, unknown>;
};
type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string };
type Result = { candidateInternalPn: string; items: Item[]; counts: Record<string, number> };
const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record<string, string> = {}) => ({
parameterName,
originalValue,
candidateValue,
constraintType,
evidenceRef: 'P-08 · P-10 Rev H',
...extra,
});
describe('边界上的 null:模型给 null 不该把工具打崩', () => {
it('**requirement / evidenceRef 给 null 照常算**(2026-08-27 W05 e2e 实测:模型写 `requirement: null` 而不是省掉,`null.trim()` 当场崩,整次调用记成「行动被拒」)', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'LM2596S-ADJ',
pairs: [
{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '1.23~37', constraintType: 'hard', unit: 'V', requirement: null, evidenceRef: null },
{ parameterName: '输入电压范围', originalValue: '4.5~40', candidateValue: '4.5~40', constraintType: 'hard', unit: 'V', requirement: '>= 40', evidenceRef: 'P-10 Rev H' },
],
},
],
});
const items = (out as { results: Array<{ items: Array<{ parameterName: string; differenceStatus: string }> }> }).results[0]!.items;
expect(items.find((i) => i.parameterName === '输出电压')?.differenceStatus).toBe('conditional');
expect(items.find((i) => i.parameterName === '输入电压范围')?.differenceStatus).toBe('match');
});
it('硬约束的 evidenceRef 给 null = 没给证据 → 不判 MATCH,退成 CONDITIONAL', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'X',
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', constraintType: 'hard', evidenceRef: null }],
},
],
});
expect((out as { results: Array<{ items: Array<{ differenceStatus: string }> }> }).results[0]!.items[0]!.differenceStatus).toBe('conditional');
});
});
describe('compare-parameters:逐参数四态', () => {
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'XL2596S-5.0',
pairs: [
pair('工作温度', '-40~+85', '-40~+125', 'hard', { unit: '℃' }),
pair('输出电压', '5.0', '5.0', 'hard', { unit: 'V' }),
pair('使能阈值', '1.4', '未披露', 'hard', { unit: 'V' }),
pair('静态电流', '5', '4.2', 'soft', { unit: 'mA' }),
],
},
{
candidateInternalPn: 'LM2596S-ADJ',
pairs: [pair('输出电压', '5.0', '1.23~37', 'hard', { unit: 'V' })],
},
],
});
const results = out['results'] as Result[];
const a = results[0]!;
const b = results[1]!;
expect(out['ruleVersion']).toBe('R-2026.07');
const by = (r: Result, name: string) => r.items.find((i) => i.parameterName === name);
expect(by(a, '工作温度')).toMatchObject({ compareResult: 'covers', differenceStatus: 'match', riskLevel: 'low' });
expect(by(a, '输出电压')).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
expect(by(a, '使能阈值')).toMatchObject({ compareResult: 'unknown', differenceStatus: 'unknown', riskLevel: 'medium' });
expect(by(a, '静态电流')).toMatchObject({ differenceStatus: 'conditional' }); // 软约束数值不同 → 条件
expect(a.counts).toMatchObject({ match: 2, unknown: 1, conditional: 1, mismatch: 0, hardMismatch: 0 });
expect(by(b, '输出电压')).toMatchObject({ compareResult: 'configurable', differenceStatus: 'conditional' });
expect(b.counts['configurable']).toBe(1);
});
it('硬约束范围变窄 → MISMATCH 高风险;客户约束(requirement)不满足也是 MISMATCH', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'CN-DCDC-32V',
pairs: [
pair('输入电压范围', '4.5~40', '4.5~32', 'hard', { unit: 'V', requirement: '>= 40' }),
pair('封装', 'TO-263-5', 'TO-263-5', 'hard'),
],
},
],
});
const c = (out['results'] as Result[])[0]!;
expect(c.items[0]).toMatchObject({ compareResult: 'narrower', differenceStatus: 'mismatch', riskLevel: 'high' });
expect(c.items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
});
it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
});
const x = (out['results'] as Result[])[0]!;
expect(x.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'conditional' });
});
it('数值带单位/括号注记也认得:「5.0(固定)」「3A」「150 kHz」;生命周期按活跃度比', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0(固定)', '5.0 V', 'hard'), pair('输出电流', '3A', '3', 'hard'), pair('开关频率', '150 kHz', '150', 'hard'), pair('生命周期', 'nrnd', 'active', 'soft')] }],
});
const x = (out['results'] as Result[])[0]!;
// 生命周期不是数值:候选比原料更活(nrnd → active)算 covers → MATCH,替 NRND 料本就是为了这个
expect(x.items.map((i) => i.differenceStatus)).toEqual(['match', 'match', 'match', 'match']);
expect(x.items[3]?.compareResult).toBe('covers');
});
});
describe('recommend:推荐等级四档', () => {
it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
const out = await call('part-substitution-assessment.recommend', {
candidates: [
{ candidateInternalPn: 'C', counts: { match: 5, mismatch: 1, unknown: 0, conditional: 0, hardMismatch: 1, softMismatch: 0, configurable: 0 } },
{ candidateInternalPn: 'A', counts: { match: 5, mismatch: 0, unknown: 1, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
{ candidateInternalPn: 'B', counts: { match: 6, mismatch: 0, unknown: 0, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } },
{ candidateInternalPn: 'D', counts: { match: 7, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
],
});
const results = out['results'] as Array<{ candidateInternalPn: string; recommendationLevel: string; riskLevel: string }>;
expect(results.map((r) => [r.candidateInternalPn, r.recommendationLevel, r.riskLevel])).toEqual([
['C', 'not_recommended', 'high'],
['A', 'conditional', 'medium'],
['B', 'alternate', 'medium'],
['D', 'recommended', 'low'],
]);
});
it('既有 UNKNOWN 又有 configurable:UNKNOWN 优先 → 条件推荐(验证闭环前不给备选)', async () => {
const out = await call('part-substitution-assessment.recommend', {
candidates: [{ candidateInternalPn: 'E', counts: { match: 1, mismatch: 0, unknown: 1, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }],
});
expect((out['results'] as Array<{ recommendationLevel: string }>)[0]?.recommendationLevel).toBe('conditional');
describe('ACT-WORK-05 两枚工具已退役', () => {
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');
});
});
@@ -0,0 +1,29 @@
/**
* **打不回的放行卡**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
* 因为这里都没有接入 ai」)。
*
* 判据只有一条:这一步整个由配方跑完、一个字的 `guide` 都没有 —— 那时打回的意见没有模型会去读,
* 配方只会原样再提交一次,撞在「打回之后台账没动过就不许再提交」那道闸上,最后落成一张
* 「连续撞在同一条判据上」的兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆。**
*/
import { describe, expect, it } from 'vitest';
import { releaseRedoable } from '../wiring/engines.ts';
describe('放行卡打回得了吗', () => {
it('全 auto(有配方、guide 是空串)→ 打不回', () => {
expect(releaseRedoable({ auto: { do: [] }, guide: '' })).toBe(false);
expect(releaseRedoable({ auto: { do: [] }, guide: ' ' })).toBe(false);
});
it('半 auto(guide 非空)→ 照旧打得回:那儿真有模型会读那句意见', () => {
expect(releaseRedoable({ auto: { do: [] }, guide: '按意见修订台账,改完重新提交' })).toBe(true);
});
it('模型驱动(压根没声明配方)→ 照旧打得回', () => {
expect(releaseRedoable({ guide: '把这一签下去会发生什么摆清楚' })).toBe(true);
});
it('认不出那一步(没声明人工验收清单的老活动)→ 老行为', () => {
expect(releaseRedoable(undefined)).toBe(true);
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest';
import { taskIdSchema } from '@contracts';
import { afterSave } from '../save-wake.ts';
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
/** 缺省有一张卡在等人 —— 「保存即铺格」的场景恰恰都是这一种 */
function spy(pending: ReadonlyArray<unknown> = [{ id: 'card-1' }]) {
const calls: string[] = [];
return {
calls,
deps: {
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
humanTool: { pendingCards: () => { calls.push('pending'); return Promise.resolve(pending); } },
},
};
}
describe('件上保存之后', () => {
it('真落下了行、且有卡在等人:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
const { calls, deps } = spy();
await afterSave(deps, TASK, 3);
expect(calls).toEqual(['repush', 'pending', 'wake']);
});
it('一行都没落下:什么也不做 —— 没变的东西不值得叫醒谁', async () => {
const { calls, deps } = spy();
await afterSave(deps, TASK, 0);
expect(calls).toEqual([]);
});
/*
* **没有卡在等人就别叫醒**(2026-08-29 终审 F1):`wake` 对 awaiting-event / awaiting-human
* 一律拨成 running,推进器随即推一拍并记一步预算 —— 而现有九个活动包一个 auto 都没有,
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
*/
/*
* **这条用例测的是 `pendingCards` 现读为空的那一种局面 —— 不是验收态**(2026-08-29 复审
* A 项二审:这里原话写着「验收态就是这一种」,是错的)。`pendingCards`
* (`human-tool-service.ts:374`)把**未办结工单**也算在内,而验收态恰恰有一张开着的
* 验收工单 —— 真在验收态时 `pendingCards` 不空,`wake` 照样会发,不落进这条分支。
*/
it('没有卡在等人(pendingCards 现读为空):件照旧重推,**不叫醒**', async () => {
const { calls, deps } = spy([]);
await afterSave(deps, TASK, 3);
expect(calls).toEqual(['repush', 'pending']);
});
it('叫醒失败不抬手,但**不再静默** —— 它是「保存即铺格」的唯一触发器', async () => {
const calls: string[] = [];
const said: string[] = [];
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
const deps = {
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
task: { wake: () => Promise.reject(new Error('库连不上')) },
humanTool: { pendingCards: () => Promise.resolve([{ id: 'card-1' }]) },
};
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
write.mockRestore();
expect(calls).toEqual(['repush']);
expect(said.join('')).toContain('库连不上');
});
it('问不出有没有卡在等人:也不叫醒,照样说一声', async () => {
const said: string[] = [];
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
const calls: string[] = [];
const deps = {
views: { repush: () => Promise.resolve(true) },
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
humanTool: { pendingCards: () => Promise.reject(new Error('库连不上')) },
};
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
write.mockRestore();
expect(calls).toEqual([]);
expect(said.join('')).toContain('库连不上');
});
});
@@ -13,6 +13,10 @@
import { describe, expect, it } from 'vitest';
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts';
const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object);
const seedRowsOf = (domain: string, object: string) =>
MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object);
describe('主数据四域', () => {
it('八个域:客户 / 项目 / 供应商 / 市场 + 料件 / 需求 / 替代 / 验证(SS02 差异评估 2026-08-26 加后四域)', () => {
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation']);
@@ -33,7 +37,8 @@ describe('主数据四域', () => {
it('样例料号:原 PN 与同族可调版在库,候选 XL2596S-5.0 刻意不播(数据工单那条路要试得出来)', () => {
const parts = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile').map((r) => r.key['internal_pn']);
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ']);
// AMS1117-3.3(LDO)是 W05 重写 Task 1 新播的第三颗 —— 「两颗料类型不一致 → 硬拦」那条路的唯一素材
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ', 'AMS1117-3.3']);
expect(parts).not.toContain('XL2596S-5.0');
});
@@ -45,7 +50,9 @@ describe('主数据四域', () => {
for (const row of params) {
expect(Object.keys(row.key).sort()).toEqual(['internal_pn', 'param_name']);
expect(row.values['param_value'], JSON.stringify(row.key)).toBeTruthy();
expect(row.values['constraint_type']).toMatch(/^(hard|soft)$/);
// 约束类型已搬到 parts.type_param 字典上(W05 重写 Task 1)—— 这张子表不再带这一列,
// 留着旧断言会重新允许「同一个参数在两颗料上各标一次约束」,正是这次改造要消灭的东西。
expect(row.values['constraint_type']).toBeUndefined();
}
// parts.profile 不再声明这一列的值 —— 两行样例主记录里它应为空(物理列留在基线,不再写)
const profiles = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile');
@@ -98,3 +105,58 @@ describe('主数据四域', () => {
}
});
});
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());
});
});
@@ -20,7 +20,7 @@
*/
import { describe, expect, it } from 'vitest';
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts';
import { createOnlyViolation, decisionReadings, missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
const RECORD = {
@@ -171,6 +171,60 @@ describe('件上加行:主键凑得齐才落得下', () => {
});
});
/**
* **Critical 1(2026-08-30 全分支终审逐行核实的链条)**:一张表**唯一**的主键
* 同时是 `auto:true` 又是 `text` 类型(W05 的 `parts.chip_type.type_id` —— 与
* `contact_code` 那种「auto 但有前缀主键陪衬」的形状不同,这里没有别的主键列可以
* 「凑出半把钥匙」)。这个组合把两条各自成立的保护同时绕开了:
* ① `barred` 认的是 `k in cells`(这一列在回传里露没露过面),不是「值是否非空」——
* 视图若干脆不给这一列挂 `data-col`,`collect()` 连这一列的空字符串都拿不到,
* `barred` 因此永远不会把它算进去;
* ② `isSeq` 认的是 declared type 是不是 `integer`/`number` —— 这一列声明成了 `text`
* (为了绕开另一条已记 debt 的坑,见 `docs/debt.md`),「序号型主键不走全表一致」
* 那条 2026-08-23 的保护对它不成立。
* 两条一起缺位,`resolveRowKey` 落到「全表一致」:表里此刻**恰好一行**(活动侧的
* 自投影机制保证了这一点)时,新建的那一行会被误判成「就是那唯一一行」,把它的名字
* /说明**静默覆盖**到既有那一行头上。
*
* 这两条用例用**真实、未改动的 `resolveRowKey`**逐字重放两种 `cells` 形状 ——
* 「视图漏挂 data-col」(旧版 `part-substitution-assessment/src/views/report.ts`
* 的形状)与「视图给了一个禁用、恒空的 data-col」(修复后的形状),证明因果链条本身:
* `resolveRowKey` 这个函数一个字没改,活动侧只是换了一种能让平台既有保护生效的
* 写法。平台侧的根治(`resolveRowKey` 该把声明了 `auto` 的列直接排除在「全表一致」
* 之外,不必等 `k in cells` 这道旁门)仍记在 `docs/debt.md`,下一个建「单一主键又是
* auto 列」的活动仍会踩上第一条用例这个坑。
*/
describe('Critical 1:唯一主键又是 auto 列(无前缀陪衬)——视图漏挂 data-col 会让新建行被误判成改既有行', () => {
const KEYS = ['type_id'];
const TYPES = { type_id: 'text' };
// 活动侧的自投影机制保证此刻 parts.chip_type 恰好一行 —— 种子里的既有类型
const ONE_EXISTING = [{ key: { type_id: '1' } }];
it('**病灶重放**:视图漏挂 data-col → type_id 压根不在 cells 里 → barred 落空 → 「全表一致」把新类型误判成既有那一行的号', () => {
const out = resolveRowKey({
keys: KEYS,
cells: { type_name: '新类型', description: '这是什么' }, // 没有 type_id 这个键 —— 与旧版 ro('type_id') 的产出一致
drafts: ONE_EXISTING,
types: TYPES,
autos: ['type_id'],
});
// 这正是 Critical 1 的病灶:落下的键是既有那一行的号,`writeEditedRows` 接下来会当成「改行」处理,
// 把新类型的 type_name/description 写进 type_id='1' 那一行,静默覆盖种子字典。
expect(out).toEqual({ type_id: '1' });
});
it('**修法重放**:视图挂了 data-col、值恒空(禁用输入格)→ type_id 在 cells 里但是空串 → barred 命中,交回 undefined 让 mintable/autoKeys 续新号', () => {
const out = resolveRowKey({
keys: KEYS,
cells: { type_id: '', type_name: '新类型', description: '这是什么' }, // 与修复后 <input data-col="type_id" disabled> 的产出一致
drafts: ONE_EXISTING,
types: TYPES,
autos: ['type_id'],
});
expect(out).toBeUndefined();
});
});
describe('提交闸:必填没填就交不了', () => {
const gate = (rows: Array<Record<string, unknown>>) =>
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
@@ -308,6 +362,43 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
});
/*
* **W05 `input-supplement` 回归网**(2026-08-29 评审 Critical:确认钮永久按不下去)。
*
* 那张数据工单卡的 `gateLedgers` 一度带着 `parts.chip_type`,而件上没有任何
* `data-edit="parts.chip_type"`(选类型走的是 `data-options` 下拉现读,不写这张表)——
* `mine:parts.chip_type` 因而永远是空数组,`emptyGate` 永远判「这一单还一行都没有」,
* 人把另外三张表填得再全,提交钮也按不下去。这条钉死两件事:
* ①**复现旧配置确实会永久卡死**(不是猜的);②**修完(去掉 chip_type)之后闸真的开得了**——
* 同一份 `mine`(chip_type 全程零行,profile/type_param/parameter 都已填好)在新
* `gateLedgers` 下必须放行。ledger 的 domain/object/label 抄自
* `activities/part-substitution-assessment/src/manifest.ts` 的声明原文。
*/
it('W05 input-supplement:gateLedgers 若带 parts.chip_type 会永久卡死(它没有编辑手段可写)——去掉之后,三张真被编辑的表填齐即放行', () => {
const watched = [
{ domain: 'parts', object: 'chip_type', label: '芯片类型字典' },
{ domain: 'parts', object: 'type_param', label: '芯片类型核心参数字段' },
{ domain: 'parts', object: 'profile', label: '芯片主数据(P-08)' },
{ domain: 'parts', object: 'parameter', label: '标准化参数集(P-08 子表)' },
];
// parts.chip_type 从未被这张件写过 —— 无论闸怎么配,它在 mine 里恒是空数组
const filledExceptChipType = {
'mine:parts.type_param': [{ type_id: '1', param_name: '输入电压范围' }],
'mine:parts.profile': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
'mine:parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }],
};
// 旧配置(已删除,这里复现只为证明它确实会卡死):chip_type 也在闸的名单里
const buggyDeclared = ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'];
const stuck = emptyGate(filledExceptChipType, watched, buggyDeclared);
expect(stuck?.ready).toBe(false);
expect(stuck?.why).toContain('芯片类型字典');
expect(stuck?.why).toContain('这张卡就是来建它的');
// 修完(case-model.ts 现在的真实值):chip_type 不列入闸的名单
const fixedDeclared = ['parts.type_param', 'parts.profile', 'parts.parameter'];
expect(emptyGate(filledExceptChipType, watched, fixedDeclared)).toBeUndefined();
});
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
@@ -550,3 +641,68 @@ describe('落不下的行要点名 —— 不许悄悄丢', () => {
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
});
});
/**
* 只建不改的表(契约 `createOnly`)—— 2026-08-27 W05:数据工单里模型多铺了一行不该建的料号,
* 件上多出一行要人填的记录、红带点名它缺品牌。这一项守「在库的料被重建」这一形。
*/
describe('createOnly:键已在正式表 → 拒并指路', () => {
const target = { label: '芯片主数据(P-08)', keys: ['internal_pn'] };
const formal = [{ internal_pn: 'LM2596S-ADJ', brand_name: 'TI' }, { internal_pn: 'LM2596S-5.0' }];
it('在库的料号写下来 → 一句带指路的拒回(点名 drop)', () => {
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal);
expect(why).toContain('已经有「internal_pn=LM2596S-ADJ」');
expect(why).toContain('drop');
});
it('本单经数据工单确认已入库的那一行 → 拒回换一句话:建好了别再写,不点名 drop(撤了读面少一行)', () => {
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal, [{ internal_pn: 'LM2596S-ADJ' }]);
expect(why).toContain('已入库');
expect(why).not.toContain('drop');
// 另一颗在库的料号与本单无关,照旧是原来那句
expect(createOnlyViolation(target, { internal_pn: 'LM2596S-5.0' }, formal, [{ internal_pn: 'LM2596S-ADJ' }])).toContain('drop');
});
it('库里没有的料号 → 放行;复合主键按全部键列比', () => {
expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined();
const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] };
const rows = [{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }];
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, rows)).toBeDefined();
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, rows)).toBeUndefined();
expect(createOnlyViolation(sub, { internal_pn: 'XL2596S-5.0', param_name: '输出电压' }, rows)).toBeUndefined();
});
});
/** 决策卡的回答是留痕里的事实(2026-08-27:审核哨兵不再读模型自写的旗) */
describe('decisionReadings:按 card-reply 数', () => {
it('最后一次选的值 + 按值计数;custom: 一律归 custom', () => {
const trail = [
{ kind: 'card-sent', detail: {} },
{ kind: 'card-reply', detail: { value: 'custom:重做一次' } },
{ kind: 'card-reply', detail: { value: 'custom:再改一下' } },
{ kind: 'card-reply', detail: { value: 'approve' } },
];
expect(decisionReadings(trail)).toEqual({ decision_last: 'approve', decision_approve_total: 1, decision_reject_total: 0, decision_custom_total: 2 });
});
it('一张都没答 → last 为空串、各计数铺 0(哨兵里 decision_last = "approve" 恒假,门关着)', () => {
expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 });
});
});
describe('conditionGateOf:确认闸的条件档(2026-08-31,「类型不一致还能确认」逼出)', () => {
it('条件为假 → 不受理,拒回用发卡人那句话;为真 / 没声明 → 不拦', async () => {
const { conditionGateOf } = await import('../routes/desk.ts');
const readings = { parts_profile_distinct_chip_type_id: 2 };
const blocked = conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '两颗料不是同一个芯片类型 —— 先改对再确认。', readings);
expect(blocked?.ready).toBe(false);
expect(blocked?.why).toContain('不是同一个芯片类型');
expect(conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '话', { parts_profile_distinct_chip_type_id: 1 })).toBeUndefined();
expect(conditionGateOf(undefined, undefined, readings)).toBeUndefined();
});
it('表达式炸了不成墙(装包静态审管坏表达式;运行时评估不出的条件不该锁死一单)', async () => {
const { conditionGateOf } = await import('../routes/desk.ts');
expect(conditionGateOf('((((', '话', {})).toBeUndefined();
});
});
@@ -0,0 +1,99 @@
/**
* 实例五态 —— 对 task 运行态 / 终态的**如实映射**(`docs/API文档-活动模块.md` §0)。
*
* pending 建了单、推进器还没领到(`used.steps = 0`;`task.start` 不同步跑第一拍)
* running 在途:running / awaiting-human / awaiting-event / suspended / landing-blocked
* completed 终态 released
* rejected 验收被负责人打回、模型还没重新提交的那一段 —— 本引擎里驳回是环内回转不是终态
* (`loop_state.pending` 为 null 或 `verdict='reject'`,且最近一条 `裁决:` 不是 pass);
* 模型重新提交、新验收卡发出后 pending 再被立起,就回到 running
* failed 终态 blocked / stalled / exhausted(kind=failed),以及 terminated(kind=terminated)
*
* 纯函数;读数由调用方从 `sys.task` / `sys.task_memory` / `sys.loop_state` 取来。
*/
import type { InstanceReason, InstanceState, TerminalState } from '@contracts';
import { TERMINAL_STATE_LABEL } from '@contracts';
/** 最近一次人的裁决(`release-verdict` 里标题以 `裁决:` 开头的那条;落地那一路也记同 kind,不算) */
export type VerdictFact = { readonly pass: boolean; readonly comment: string; readonly byName: string | undefined };
/** 在途验收(`loop_state.pending`):卡发出即有;verdict 到达后填上;重做被消费后整个清空 */
export type PendingFact = { readonly verdict: 'pass' | 'reject' | undefined; readonly comment: string | undefined };
export type StateFacts = {
readonly terminal: TerminalState | undefined;
readonly reason: string | undefined;
readonly runtime: string;
readonly usedSteps: number;
readonly lastVerdict: VerdictFact | undefined;
readonly pending: PendingFact | undefined;
};
export type StateVerdict = { readonly state: InstanceState; readonly reason: InstanceReason | null };
export const VERDICT_PREFIX = '裁决:';
function rejectedReason(comment: string, byName: string | undefined): InstanceReason {
if (comment === '') return { kind: 'rejected', text: '验收驳回:负责人未写明具体意见,已退回重做' };
return { kind: 'rejected', text: `验收驳回:${comment}${byName === undefined ? '' : ` —— 负责人 ${byName}`}` };
}
const BUDGET_ITEM_LABEL: Readonly<Record<string, string>> = { steps: '步数', tokens: 'token', activeMs: '活跃时长', humanCalls: '调人次数' };
/**
* `exhausted` 那一路 `sys.task.reason` 存的是预算执行器裁决的 JSON(`{"kind":"exhausted","breaches":[{item,used,cap}]}`)——
* 机器话;摆给人的一句要说「哪一项、用了多少、上限多少」。认不出的形状原样交出去(不编)。
*/
export function failedReasonText(terminal: TerminalState, reason: string | undefined): string {
if (reason === undefined || reason === '') return TERMINAL_STATE_LABEL[terminal];
if (terminal !== 'exhausted') return reason;
try {
const parsed: unknown = JSON.parse(reason);
const breaches = typeof parsed === 'object' && parsed !== null ? (parsed as { breaches?: unknown }).breaches : undefined;
if (!Array.isArray(breaches) || breaches.length === 0) return reason;
const parts = breaches.map((b) => {
const one = (typeof b === 'object' && b !== null ? b : {}) as { item?: unknown; used?: unknown; cap?: unknown };
const item = typeof one.item === 'string' ? one.item : '';
const scale = item === 'activeMs' ? 60_000 : 1;
const fmt = (v: unknown) => (typeof v === 'number' ? String(Math.round(v / scale)) : '?');
return `${BUDGET_ITEM_LABEL[item] ?? item} ${fmt(one.used)}/${fmt(one.cap)}${item === 'activeMs' ? ' 分钟' : ''}`;
});
return `预算耗尽:${parts.join('、')}`;
} catch {
return reason;
}
}
export function instanceStateOf(f: StateFacts): StateVerdict {
if (f.terminal === 'released') return { state: 'completed', reason: null };
if (f.terminal === 'terminated') return { state: 'failed', reason: { kind: 'terminated', text: f.reason ?? '已手动终止' } };
if (f.terminal !== undefined) {
return { state: 'failed', reason: { kind: 'failed', text: failedReasonText(f.terminal, f.reason) } };
}
if (f.pending?.verdict === 'reject') {
return { state: 'rejected', reason: rejectedReason((f.pending.comment ?? f.lastVerdict?.comment ?? '').trim(), f.lastVerdict?.byName) };
}
if (f.pending === undefined && f.lastVerdict !== undefined && !f.lastVerdict.pass) {
return { state: 'rejected', reason: rejectedReason(f.lastVerdict.comment.trim(), f.lastVerdict.byName) };
}
if (f.runtime === 'running' && f.usedSteps === 0) return { state: 'pending', reason: null };
return { state: 'running', reason: null };
}
/** 从任务记忆里摘最近一次人的裁决;`nameOf` 把 `detail.by`(staffId)换成名字 */
export function verdictFactOf(
entries: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
nameOf: (staffId: string) => string | undefined = () => undefined,
): VerdictFact | undefined {
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry === undefined || entry.kind !== 'release-verdict' || !entry.title.startsWith(VERDICT_PREFIX)) continue;
const by = entry.detail?.['by'];
const comment = entry.detail?.['comment'];
return {
pass: entry.title === `${VERDICT_PREFIX}pass`,
comment: typeof comment === 'string' ? comment : '',
byName: typeof by === 'string' ? nameOf(by) : undefined,
};
}
return undefined;
}
@@ -0,0 +1,114 @@
/**
* L3 的「动作过程记录」—— 一个计划项一步,按案例模型的文档序排。
*
* 三处拼起来(没有哪一处单独够用):
* · `loop_state.parsed.items`(CMMN 计划项:名字、谁办 —— `manualActivation` 的项是触达人的那几步);
* · `loop_state.runtime.items[].state`(办到哪了:Completed / Active / Failed / …,**不带时间**);
* · 任务记忆里引擎写的三种痕(`开始办:<名>` / `完成:<名>` / `自动收步:<id> …`)给出时刻与用时。
* 阶段(stage)/ 里程碑 / 监听器不是「一步」,不摆。
*/
import type { InstanceState, InstanceStep } from '@contracts';
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
export const STEP_ITEM_TYPES: ReadonlySet<string> = new Set(['humanTask', 'decisionTask', 'processTask', 'caseTask']);
export const STEP_START = '开始办:';
export const STEP_DONE = '完成:';
export const STEP_AUTO = '自动收步:';
export type HistoryLine = { readonly kind: string; readonly title: string; readonly at: string };
export type StepFacts = {
readonly items: readonly PlanItem[];
readonly runtimeItems: readonly ItemRuntime[];
/** 剧本 `steps[].intent`(item id → 一句话) */
readonly intents: ReadonlyMap<string, string>;
readonly history: readonly HistoryLine[];
readonly state: InstanceState;
readonly ownerName: string;
readonly llm: string;
};
type Res = InstanceStep['res'];
const IN_PROGRESS: ReadonlySet<string> = new Set(['Active', 'Enabled', 'Failed', 'Suspended']);
function baseRes(state: string | undefined): Res {
if (state === 'Completed') return 'done';
if (state === 'Active' || state === 'Enabled') return 'current';
if (state === 'Failed') return 'fail';
return 'pending';
}
/** 同一项可能重复实例化(repetitionRule),看最新那一实例 */
function latestRuntime(items: readonly ItemRuntime[], id: string): ItemRuntime | undefined {
let best: ItemRuntime | undefined;
for (const it of items) if (it.id === id && (best === undefined || it.instance > best.instance)) best = it;
return best;
}
function millis(iso: string): number | undefined {
const t = Date.parse(iso);
return Number.isFinite(t) ? t : undefined;
}
export function stepsOf(f: StepFacts): InstanceStep[] {
const rows = f.items
.filter((item) => STEP_ITEM_TYPES.has(item.type))
.map((item) => {
const rt = latestRuntime(f.runtimeItems, item.id);
return { item, rt, res: baseRes(rt?.state) as Res };
});
/*
* 失败 / 驳回的那一行:正在办的那一步记 fail(过程面「当前步记已终止 / 驳回」)。
* 没有正在办的(终止前一步没开、或验收打回时项都已收)→ 失败取第一个没到的,驳回取最后一个已完成的。
*/
if (f.state === 'failed' || f.state === 'rejected') {
const live = rows.findIndex((r) => r.rt !== undefined && IN_PROGRESS.has(r.rt.state));
if (live >= 0) rows[live]!.res = 'fail';
else if (f.state === 'failed') {
const next = rows.findIndex((r) => r.res === 'pending');
if (next >= 0) rows[next]!.res = 'fail';
} else {
for (let i = rows.length - 1; i >= 0; i -= 1) {
if (rows[i]!.res === 'done') {
rows[i]!.res = 'fail';
break;
}
}
}
}
return rows.map(({ item, res }, i) => {
const start = f.history.find((h) => h.title === `${STEP_START}${item.name}`);
const ends = f.history.filter((h) => h.title === `${STEP_DONE}${item.name}` || h.title.startsWith(`${STEP_AUTO}${item.id} `));
const end = ends[ends.length - 1];
const startMs = start === undefined ? undefined : millis(start.at);
const endMs = end === undefined ? undefined : millis(end.at);
const at = start?.at ?? (res === 'done' ? (end?.at ?? null) : null);
const durationMs = startMs !== undefined && endMs !== undefined && endMs >= startMs && res !== 'current' ? endMs - startMs : null;
return {
seq: i + 1,
name: item.name,
by: item.manualActivation ? 'human' : 'agent',
byName: item.manualActivation ? f.ownerName : f.llm,
at,
durationMs,
res,
note: f.intents.get(item.id) ?? '',
};
});
}
/**
* 让某一步的结果**跟随**前面那一步(2026-08-28 用户定:详情接口交给前端之前,第 9 步的 res 跟第 8 步走)。
*
* 为什么会需要:放行那一步(T_release,人工激活)没有自己的收步事件 —— 人签字后 `submit` 直接把整单落终态,
* 计划项本身从没被 finish 过,于是一单已 `released` 的详情里第 9 步永远是 `pending`,而它前面拟落账那一步早就 `done`。
* 改 `res` 与 `at`(2026-08-28 用户定,`at` 也跟);用时不编(那一步没有自己的起止痕)。两步有一步不在(别的活动步数不同)就什么都不动。
*/
export function followRes(steps: readonly InstanceStep[], rule: { readonly seq: number; readonly from: number }): InstanceStep[] {
const source = steps.find((s) => s.seq === rule.from);
if (source === undefined) return [...steps];
return steps.map((s) => (s.seq === rule.seq ? { ...s, res: source.res, at: source.at } : s));
}
@@ -0,0 +1,325 @@
/**
* 启动参数表 —— 从活动契约的 `input.data`(JSON Schema)派生,一活动一份;前端只按它渲染,不写死字段。
*
* 清单里怎么写(都是 JSON Schema 的标准词或本仓已有的非标词,`jsonSchemaSchema` 是 `record(unknown)`,扩展键能过):
* · `title` → label;`description` → desc;`examples[0]` → placeholder;`default` → default;`required[]` → required
* · `enum` / `enumNames` → select 的候选(E04 / I01 已在用)
* · `x-control`:`text | textarea | select | combo` —— 不写就按类型推:enum → select、`array<string>` → list、string → text;
* **数组字段也可以标 combo / select**(表单只选一颗,提交单个字符串,后端包成一元数组;传数组也照收)
* · `x-options`:`{ ledger: '域.对象', value: '列', label: '列' }` —— combo / select 的候选从正式台账现读(客户 / 料号这类「台账里可能有」的)
* · `x-options.group`:候选的**分组键**(2026-08-31 用户定)—— 只写列名(`'chip_type_id'`)= 有键没名;
* 整份写 `{ column, ledger, value, label }` 再从字典表换人读名(`parts.chip_type.type_id → type_name`)
* · `x-link`:联动组名 —— 同名的几格按候选的分组键互相收窄(组内先填的那格是锚,其余按锚的键收窄)。
* **服务端不据此立墙**:combo 本就收候选之外的值(新增料号),类型一致归活动自己的收步判据管
* · `x-distinct`:互斥组名 —— 同名的几格**不能填同一个值**。这条**是墙**(与 `x-link` 正相反):
* 前端不把别的格占了的候选列出来,自己写进来的由这里拒。理由见契约 `distinctGroup` 的注释:
* W05 两格同一颗料会把单子做死,而唯一能改的时机就是起单这一刻
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
*
* 校验与归一(`validateLaunchParams`)是服务端那一道:前端禁提交只是礼貌,拦住的得是这里。
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
*/
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
import { LAUNCH_PARAM_TYPES, launchDistinctKey, launchDistinctMessage } from '@contracts';
/**
* 候选的**分组键**(2026-08-31 用户定的联动):`column` 是候选行上取哪一列当键(如 `chip_type_id`),
* `dict` 是把这枚键换成人读名的字典表(如 `parts.chip_type.type_id → type_name`);
* 字典没声明 / 写残 → 只有键没有名(界面退回摆键本身,不炸)。
*/
export type GroupSource = { readonly column: string; readonly dict: { readonly ledger: string; readonly value: string; readonly label: string } | undefined };
export type OptionsSource = { readonly ledger: string; readonly value: string; readonly label: string; readonly group: GroupSource | undefined };
/** 一个入参字段的规格 —— JSON Schema 里我们认的那几个词 */
export type FieldSpec = {
readonly key: string;
readonly required: boolean;
readonly title: string | undefined;
readonly description: string | undefined;
readonly type: string | undefined;
readonly itemType: string | undefined;
readonly format: string | undefined;
readonly control: string | undefined;
readonly enumValues: readonly unknown[] | undefined;
readonly enumNames: readonly unknown[] | undefined;
readonly example: unknown;
readonly defaultValue: unknown;
readonly optionsSource: OptionsSource | undefined;
/** 联动组名(`x-link`);同名的几格按候选的分组键互相收窄 —— 摆法在前端,服务端不据此立墙 */
readonly linkGroup: string | undefined;
/** 互斥组名(`x-distinct`);同名的几格不能填同一个值 —— 这一条服务端真拦 */
readonly distinctGroup: string | undefined;
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
readonly order: number | undefined;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
/** `x-options.group`:只写列名(字符串)= 有键没名;整份声明 = 键 + 换名字典。写残当没声明 —— 候选照出,只是不联动 */
function groupSourceOf(value: unknown): GroupSource | undefined {
const asColumn = asString(value);
if (asColumn !== undefined) return asColumn === '' ? undefined : { column: asColumn, dict: undefined };
const raw = asRecord(value);
if (raw === undefined) return undefined;
const column = asString(raw['column']);
if (column === undefined || column === '') return undefined;
const ledger = asString(raw['ledger']);
const valueCol = asString(raw['value']);
const labelCol = asString(raw['label']);
const dict =
ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === ''
? undefined
: { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol };
return { column, dict };
}
function optionsSourceOf(value: unknown): OptionsSource | undefined {
const raw = asRecord(value);
if (raw === undefined) return undefined;
const ledger = asString(raw['ledger']);
const valueCol = asString(raw['value']);
const labelCol = asString(raw['label']);
if (ledger === undefined || !ledger.includes('.') || valueCol === undefined || valueCol === '') return undefined;
return { ledger, value: valueCol, label: labelCol === undefined || labelCol === '' ? valueCol : labelCol, group: groupSourceOf(raw['group']) };
}
/** `input.data` 的 properties 逐个读成规格,保属性序(表单顺序就是清单顺序) */
export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): FieldSpec[] {
const props = asRecord(inputSchema['properties']) ?? {};
const requiredRaw = inputSchema['required'];
const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k): k is string => typeof k === 'string') : [];
const specs = Object.entries(props).map(([key, raw]): FieldSpec => {
const spec = asRecord(raw) ?? {};
const items = asRecord(spec['items']);
const enumRaw = spec['enum'];
const namesRaw = spec['enumNames'];
const examples = spec['examples'];
return {
key,
required: required.includes(key),
title: asString(spec['title']),
description: asString(spec['description']),
type: asString(spec['type']),
itemType: items === undefined ? undefined : asString(items['type']),
format: asString(spec['format']),
control: asString(spec['x-control']),
enumValues: Array.isArray(enumRaw) ? enumRaw : undefined,
enumNames: Array.isArray(namesRaw) ? namesRaw : undefined,
example: Array.isArray(examples) ? examples[0] : undefined,
defaultValue: spec['default'],
optionsSource: optionsSourceOf(spec['x-options']),
linkGroup: asString(spec['x-link']) === '' ? undefined : asString(spec['x-link']),
distinctGroup: asString(spec['x-distinct']) === '' ? undefined : asString(spec['x-distinct']),
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
};
});
// 稳定排序:声明了 x-order 的按数字,没声明的殿后并保持原序
return specs
.map((spec, i) => ({ spec, i }))
.sort((a, b) => (a.spec.order ?? Number.POSITIVE_INFINITY) - (b.spec.order ?? Number.POSITIVE_INFINITY) || a.i - b.i)
.map((x) => x.spec);
}
function isLaunchType(value: string | undefined): value is LaunchParamType {
return value !== undefined && (LAUNCH_PARAM_TYPES as readonly string[]).includes(value);
}
/**
* 这一格该长成什么控件;`undefined` = 本期不上表单(文件 / 对象 / 非字符串数组)。
* 数组一律 list(`x-control` 对数组不生效 —— 一串值只有一种摆法);标量按 `x-control` > enum > 文本。
*/
export function controlOf(spec: FieldSpec): LaunchParamType | undefined {
if (spec.format === 'file') return undefined;
if (spec.type === 'array') {
if (spec.itemType !== undefined && spec.itemType !== 'string') return undefined;
// 数组字段也可以摆成单选(`x-control: combo | select | text`):表单上只选一颗,提交时后端包成一元数组
return isLaunchType(spec.control) && spec.control !== 'list' ? spec.control : 'list';
}
if (spec.type === 'object') return undefined;
if (isLaunchType(spec.control) && spec.control !== 'list') return spec.control;
if (spec.enumValues !== undefined && spec.enumValues.length > 0) return 'select';
return 'text';
}
/** 枚举 → 候选:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
export function enumOptionsOf(spec: FieldSpec): LaunchOption[] | undefined {
if (spec.enumValues === undefined || spec.enumValues.length === 0) return undefined;
return spec.enumValues.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
}
function placeholderOf(spec: FieldSpec, type: LaunchParamType): string | undefined {
const example = spec.example;
if (example === undefined || example === null) return undefined;
// 数组字段(list,或摆成单选的数组)的示例是一串:占位摆第一颗
if (type === 'list' || spec.type === 'array') {
const first = Array.isArray(example) ? example[0] : example;
return typeof first === 'string' ? first : undefined;
}
return typeof example === 'string' ? example : JSON.stringify(example);
}
function defaultOf(spec: FieldSpec, type: LaunchParamType): string | string[] | undefined {
const value = spec.defaultValue;
if (type === 'list') {
return Array.isArray(value) && value.every((v): v is string => typeof v === 'string') ? value : undefined;
}
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined;
return typeof value === 'string' ? value : undefined;
}
/**
* 一格规格 → 一条参数。`resolved` 是从台账读回来的候选(`x-options`);没有就退回枚举。
* select 没有任何候选就降成 text —— 「只能选」却无可选,等于把这一格锁死。
*/
export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[] | undefined): LaunchParam | undefined {
let type = controlOf(spec);
if (type === undefined) return undefined;
const options = resolved === undefined ? enumOptionsOf(spec) : [...resolved];
if (type === 'select' && (options === undefined || options.length === 0)) type = 'text';
const withOptions = (type === 'select' || type === 'combo') && options !== undefined && options.length > 0;
const placeholder = placeholderOf(spec, type);
const fallback = defaultOf(spec, type);
return {
key: spec.key,
label: spec.title ?? spec.key,
type,
...(spec.required ? { required: true } : {}),
...(spec.description === undefined ? {} : { desc: spec.description }),
...(placeholder === undefined ? {} : { placeholder }),
...(withOptions ? { options } : {}),
...(fallback === undefined ? {} : { default: fallback }),
/* 联动只在真有候选时才谈得上 —— 一个候选都没有的 combo 摆了组名,前端也没有可收窄的东西 */
...(withOptions && spec.linkGroup !== undefined ? { linkGroup: spec.linkGroup } : {}),
/* 互斥**不以有候选为前提**:两格都自己写、写成同一个,照样要拦 —— 墙管的是值,不是候选 */
...(spec.distinctGroup === undefined ? {} : { distinctGroup: spec.distinctGroup }),
};
}
/** 整份参数表;候选读取由调用方注入(读不到就当没有候选,**不炸**) */
export async function launchParamsOf(
inputSchema: Readonly<Record<string, unknown>>,
resolve: (source: OptionsSource) => Promise<readonly LaunchOption[] | undefined>,
): Promise<LaunchParam[]> {
const out: LaunchParam[] = [];
for (const spec of fieldSpecsOf(inputSchema)) {
const resolved = spec.optionsSource === undefined ? undefined : await resolve(spec.optionsSource);
const param = launchParamOf(spec, resolved);
if (param !== undefined) out.push(param);
}
return out;
}
export type LaunchIssue = { readonly key: string; readonly message: string };
/** 线上的形状:list 是字符串数组,其余是字符串 */
export type LaunchWire = Readonly<Record<string, string | readonly string[]>>;
export type LaunchValidation =
| { readonly ok: true; readonly data: Record<string, unknown> }
| { readonly ok: false; readonly issues: readonly LaunchIssue[] };
function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unknown } | { error: string } {
const type = spec?.type;
if (type === 'integer' || type === 'number') {
const n = Number(value);
if (!Number.isFinite(n) || (type === 'integer' && !Number.isInteger(n))) return { error: `要填${type === 'integer' ? '整数' : '数字'}` };
return { value: n };
}
if (type === 'boolean') {
if (value === 'true' || value === 'false') return { value: value === 'true' };
return { error: '只能是 true / false' };
}
return { value };
}
/**
* 同一互斥组(`x-distinct`)里撞了同一个值的那几格 —— 点名的是**后填的那格**(表单次序在后的),
* 话里指向它撞上了谁。只认标量:数组格不参与(见契约 `distinctGroup` 的注释)。
* 「算不算同一个」由 `launchDistinctKey` 说了算 —— 前端用的是同一把尺。
*/
function distinctIssuesOf(params: readonly LaunchParam[], data: Readonly<Record<string, unknown>>): LaunchIssue[] {
const issues: LaunchIssue[] = [];
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
for (const param of params) {
const group = param.distinctGroup;
const value = data[param.key];
if (group === undefined || value === undefined || value === null || typeof value === 'object') continue;
const key = launchDistinctKey(String(value));
if (key === '') continue;
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
seenPerGroup.set(group, seen);
const first = seen.get(key);
if (first === undefined) seen.set(key, param);
else issues.push({ key: param.key, message: launchDistinctMessage(first.label) });
}
return issues;
}
/**
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
* **互斥组(`x-distinct`)是这里唯一的跨格校验**:同组两格填了同一个值就拒 —— 前端不列出别人占了的
* 候选只是礼貌,自己写一个同样的值、或压根不经这个表单起单(脚本 / API),拦住的得是这里。
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
*/
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
const issues: LaunchIssue[] = [];
const data: Record<string, unknown> = {};
const known = new Set(params.map((p) => p.key));
const specOf = new Map(specs.map((s) => [s.key, s]));
for (const key of Object.keys(input)) {
if (!known.has(key)) issues.push({ key, message: '不是这个活动的启动参数' });
}
for (const param of params) {
const raw = input[param.key];
const required = param.required === true;
const spec = specOf.get(param.key);
/*
* 清单里是数组的字段(list,或摆成单选的 combo / select):线上 string 与 string[] 都收,
* 归一成 string[] 交活动 —— 前端单选传一个字符串,后端包成一元数组(2026-08-27 用户定)。
*/
if (param.type === 'list' || spec?.type === 'array') {
const entries = (typeof raw === 'string' ? [raw] : (raw ?? [])).map((v) => v.trim()).filter((v) => v !== '');
if (entries.length === 0) {
if (required) issues.push({ key: param.key, message: param.type === 'list' ? '必填,至少一项' : '必填' });
continue;
}
if (param.type === 'select' && param.options !== undefined) {
const off = entries.find((v) => !param.options?.some((o) => o.value === v));
if (off !== undefined) {
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
continue;
}
}
data[param.key] = entries;
continue;
}
if (raw !== undefined && typeof raw !== 'string') {
issues.push({ key: param.key, message: '要传字符串' });
continue;
}
const value = (raw ?? '').trim();
if (value === '') {
if (required) issues.push({ key: param.key, message: '必填' });
continue;
}
if (param.type === 'select' && param.options !== undefined && !param.options.some((o) => o.value === value)) {
issues.push({ key: param.key, message: '不在候选里(单选只能选候选)' });
continue;
}
const coerced = coerceScalar(value, spec);
if ('error' in coerced) {
issues.push({ key: param.key, message: coerced.error });
continue;
}
data[param.key] = coerced.value;
}
issues.push(...distinctIssuesOf(params, data));
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
}
+602
View File
@@ -0,0 +1,602 @@
/**
* 运营中心 · 活动模块的用例编排(app 层;`docs/API文档-活动模块.md`)。
*
* 读是跨模块的投影:活动 = 注册表卡片 + 清单 + 运营侧档案 + 实例计数;
* 实例 = `sys.task` 一行 + 任务记忆里的裁决与引擎痕 + loop 运行态 + 输入 / 产出文件。
* 写一律走既有 port:`task.start`(`origin:'ops'`)、`task.control('terminate')`、`registry.setEnabled`。
*
* 启动的幂等:`sys.activity_launch` 按键占位(一句 SQL 判 + 占),同键重放回第一次那一单;
* 键占了、单还没建成的那 60 秒里再来 = 409(不是第二单,也不是无限等)。
*/
import type {
LedgerQuery,
ViewData,
ActivityDetail,
ActivityHue,
ActivityStatus,
ActivitySummary,
InstanceDetail,
InstanceFile,
InstanceList,
InstanceSource,
InstanceState,
InstanceSummary,
LaunchInput,
LaunchOption,
LaunchParams,
StaffId,
StaffRef,
TaskId,
TerminalState,
} from '@contracts';
import { ACTIVITY_HUES, TERMINAL_STATES, staffIdSchema, taskIdSchema } from '@contracts';
import type { ActivityCard } from '@modules/activity-registry';
import type { ItemRuntime, PlanItem } from '@modules/loop-engine';
import { StaffNotFoundError } from '@modules/org';
import { renderView } from '@modules/interaction';
import type { StartTaskInput } from '@modules/task';
import { MIME_OF } from '../file-compose.ts';
import type { FileFormat } from '../file-compose.ts';
import { fieldSpecsOf, launchParamsOf, validateLaunchParams } from './launch-params.ts';
import type { FieldSpec, GroupSource, LaunchIssue, OptionsSource } from './launch-params.ts';
import { instanceStateOf, verdictFactOf } from './instance-state.ts';
import type { StateVerdict } from './instance-state.ts';
import { followRes, stepsOf } from './instance-steps.ts';
import { applyInstanceFilters, inputFilesOf, inputSummaryOf, outputSummaryOf, pageOf, statsOf } from './summaries.ts';
import type { InstanceQuery } from './summaries.ts';
import { LEDGER_TEMPLATE, injectHeightScript } from './view-html.ts';
import { resolveViewData, viewScopeOf } from './view-scope.ts';
import type { ViewRead } from './view-scope.ts';
import { caseStamp } from '../case-stamp.ts';
import type { ActivityOpsStore, TaskRow } from './store.ts';
/** 键占了、单还没建成:这么久之后允许重占(进程崩在 claim 与 start 之间不该把这枚键锁死) */
export const LAUNCH_CLAIM_STALE_MS = 60_000;
/** combo 候选内联上限(文档 §4:本期一律内联;更大的表以后走检索接口) */
export const OPTIONS_CAP = 500;
/** 详情时间线交给前端之前:第 9 步(放行)的结果跟第 8 步(拟落账)走 —— 见 `followRes` 的注释(2026-08-28 用户定) */
export const STEP_FOLLOW_RULE = { seq: 9, from: 8 } as const;
/** 输入 / 产出文件的存取 —— app 里现成的两段(`upload-store.ts` / `asset-archive.ts`),按接口注入好替身 */
export type InstanceFileStore = {
readUpload(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
listArchived(taskId: TaskId): Promise<ReadonlyArray<{ address: string; key: Record<string, string>; format: FileFormat; name: string; size: number }>>;
readArchived(taskId: TaskId, address: string, key: Readonly<Record<string, string>>): Promise<{ bytes: Uint8Array; name: string; format: FileFormat } | undefined>;
};
/*
* 依赖只声明**用到的那几面**(不是整个 `ActivityRegistryService` / `TaskService` …):
* 真服务结构上都装得进来(server.ts 装配处由 tsc 核),测试只需拼几个字面量,不用造整本清单。
*/
/** 清单里运营面要看的几处(台账声明只为沙箱看板铺 tally 读数) */
export type ManifestFace = {
readonly identity: { readonly name: string };
readonly contract: { readonly input: { readonly data: Readonly<Record<string, unknown>> } };
readonly declarations: {
readonly models: readonly string[];
readonly ledger: ReadonlyArray<{ readonly domain: string; readonly object: string; readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }> }>;
};
readonly assets: { readonly viewTemplates: ReadonlyArray<{ readonly name: string; readonly html: string; readonly data?: ViewData | undefined }> };
};
export type RegistryFace = {
list(): Promise<ActivityCard[]>;
get(activityId: string): Promise<ActivityCard | undefined>;
manifestOf(activityId: string, packageVersion?: string): Promise<ManifestFace>;
setEnabled(activityId: string, enabled: boolean): Promise<void>;
};
export type TaskFace = {
start(input: StartTaskInput): Promise<{ readonly id: TaskId }>;
control(taskId: TaskId, semantic: 'terminate', by: StaffId, reason?: string): Promise<unknown>;
};
export type OrgFace = {
listStaff(): Promise<{ staff: ReadonlyArray<{ readonly id: string; readonly name: string }> }>;
getStaff(staffId: StaffId): Promise<{ readonly name: string; readonly state: string }>;
};
export type HistoryEntry = { readonly kind: string; readonly title: string; readonly at: string; readonly detail?: Record<string, unknown> | undefined };
export type MemoryFace = { history(taskId: TaskId): Promise<ReadonlyArray<HistoryEntry>> };
export type LedgerFace = {
describe(address: { domain: string; object: string }): Promise<{ readonly columns: ReadonlyArray<{ readonly name: string }> } | undefined>;
query(query: LedgerQuery): Promise<{ readonly rows: ReadonlyArray<Readonly<Record<string, unknown>>> }>;
};
export type LoopFace = {
readonly model: { readonly steps?: ReadonlyArray<{ readonly item: string; readonly intent: string }> | undefined };
readonly parsed: { readonly items: readonly PlanItem[] };
readonly runtime: { readonly items: readonly ItemRuntime[] };
readonly pending?: { readonly verdict?: 'pass' | 'reject' | undefined; readonly comment?: string | undefined } | undefined;
};
export type LoopStateFace = { load(taskId: TaskId): Promise<LoopFace | undefined> };
export type ActivityOpsDeps = {
readonly registry: RegistryFace;
readonly task: TaskFace;
readonly org: OrgFace;
readonly memory: MemoryFace;
readonly ledger: LedgerFace;
readonly loopState: LoopStateFace;
readonly store: ActivityOpsStore;
readonly files: InstanceFileStore;
readonly clock: { now(): Date };
/** 读不到候选这类**不该炸**的事,说一声就好 */
readonly warn?: (line: string) => void;
};
export type Actor = { readonly id: StaffId; readonly name: string };
export type LaunchOutcome =
| { readonly kind: 'ok'; readonly detail: InstanceDetail; readonly created: boolean }
| { readonly kind: 'not-found' }
| { readonly kind: 'disabled' }
| { readonly kind: 'invalid'; readonly issues: readonly LaunchIssue[] }
| { readonly kind: 'owner-invalid'; readonly message: string }
| { readonly kind: 'busy'; readonly message: string };
export type TerminateOutcome = { readonly kind: 'ok'; readonly detail: InstanceDetail } | { readonly kind: 'not-found' } | { readonly kind: 'terminal' };
export type FileOutcome =
| { readonly kind: 'file'; readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }
| { readonly kind: 'no-instance' }
| { readonly kind: 'no-file' };
export type ViewOutcome = { readonly kind: 'html'; readonly html: string } | { readonly kind: 'no-activity' } | { readonly kind: 'no-view' };
export type ActivityOps = {
listActivities(status?: ActivityStatus): Promise<ActivitySummary[]>;
getActivity(activityId: string): Promise<ActivityDetail | undefined>;
viewHtml(activityId: string): Promise<ViewOutcome>;
listInstances(activityId: string, query: InstanceQuery & { page: number; size: number }): Promise<InstanceList | undefined>;
launchParams(activityId: string): Promise<LaunchParams | undefined>;
launch(activityId: string, input: LaunchInput, by: Actor): Promise<LaunchOutcome>;
instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined>;
instanceFile(activityId: string, instanceId: string, io: 'input' | 'output', index: number): Promise<FileOutcome>;
setEnabled(activityId: string, enabled: boolean): Promise<ActivityDetail | undefined>;
terminate(activityId: string, instanceId: string, by: Actor): Promise<TerminateOutcome>;
};
const NAME_FALLBACK = '员工';
function isTerminal(value: string | null): value is TerminalState {
return value !== null && (TERMINAL_STATES as readonly string[]).includes(value);
}
function hueOf(raw: string | null): ActivityHue | undefined {
return raw !== null && (ACTIVITY_HUES as readonly string[]).includes(raw) ? (raw as ActivityHue) : undefined;
}
/** 当前生效的包版本:非 uninstalled 的最新一版(store 按 installedAt 升序给) */
function versionOf(card: ActivityCard): string {
const live = card.packageVersions.filter((p) => p.state !== 'uninstalled');
const pick = (live.length > 0 ? live : card.packageVersions).at(-1);
return pick?.version ?? '';
}
function createdAtOf(card: ActivityCard): string {
let earliest: string | undefined;
for (const p of card.packageVersions) if (earliest === undefined || p.installedAt < earliest) earliest = p.installedAt;
return earliest ?? '';
}
function llmOf(manifest: ManifestFace): string {
return manifest.declarations.models[0] ?? '—';
}
function channelLabel(channel: string): string {
if (channel === 'mail') return '邮件';
if (channel === 'im') return '即时消息';
return channel === '' ? '通道' : channel;
}
export function createActivityOps(deps: ActivityOpsDeps): ActivityOps {
const warn = deps.warn ?? ((line: string) => process.stderr.write(`${line}\n`));
async function namesOf(): Promise<Map<string, string>> {
const { staff } = await deps.org.listStaff();
return new Map(staff.map((s) => [s.id, s.name]));
}
function staffRef(id: string, names: ReadonlyMap<string, string>): StaffRef {
return { id: staffIdSchema.parse(id), name: names.get(id) ?? NAME_FALLBACK };
}
function summaryOf(
card: ActivityCard,
manifest: ManifestFace,
profile: { ownerId: string | null; description: string; ico: string | null; hue: string | null } | undefined,
instanceCount: number,
names: ReadonlyMap<string, string>,
): ActivitySummary {
const hue = hueOf(profile?.hue ?? null);
return {
id: card.activityId,
name: card.name,
biz: card.category,
status: card.enabled ? 'active' : 'disabled',
version: versionOf(card),
owner: profile?.ownerId === null || profile?.ownerId === undefined ? null : staffRef(profile.ownerId, names),
llm: llmOf(manifest),
instanceCount,
createdAt: createdAtOf(card),
...(profile?.ico === null || profile?.ico === undefined || profile.ico === '' ? {} : { ico: profile.ico }),
...(hue === undefined ? {} : { hue }),
};
}
async function detailOf(card: ActivityCard): Promise<ActivityDetail> {
const [manifest, profiles, names] = await Promise.all([deps.registry.manifestOf(card.activityId), deps.store.profilesOf(), namesOf()]);
const profile = profiles.get(card.activityId);
const { instanceCount: _drop, ...rest } = summaryOf(card, manifest, profile, 0, names);
void _drop;
const hasView = manifest.assets.viewTemplates.some((v) => v.name === LEDGER_TEMPLATE);
return { ...rest, desc: profile?.description ?? '', view: hasView ? { url: `/api/gov/activities/${card.activityId}/view` } : null };
}
/**
* 分组键的人读名字典(`x-options.group.dict`)—— 键 → 名。
* 表没物化 / 列不在 / 查不动 → 空表(候选仍带键、只是没有名;界面退回摆键本身,不炸)。
*/
async function groupLabels(dict: NonNullable<GroupSource['dict']>): Promise<ReadonlyMap<string, string>> {
const [domain, object] = dict.ledger.split('.', 2);
if (domain === undefined || object === undefined || domain === '' || object === '') return new Map();
const address = { domain, object };
const described = await deps.ledger.describe(address).catch(() => undefined);
if (described === undefined || !described.columns.some((c) => c.name === dict.value) || !described.columns.some((c) => c.name === dict.label)) {
warn(`[activity-ops] 分组人读名读不到:${dict.ledger} 未物化或没有列 ${dict.value} / ${dict.label}`);
return new Map();
}
const page = await deps.ledger.query({ address, where: {}, orderBy: [], limit: OPTIONS_CAP, offset: 0 }).catch((error: unknown) => {
warn(`[activity-ops] 分组人读名查询失败:${dict.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
return undefined;
});
const out = new Map<string, string>();
for (const row of page?.rows ?? []) {
const key = row[dict.value];
const name = row[dict.label];
if (key === undefined || key === null || String(key) === '') continue;
if (name === undefined || name === null || String(name) === '') continue;
out.set(String(key), String(name));
}
return out;
}
/** combo / select 的候选从正式台账现读;表没物化、列不存在、查询失败 → 没有候选(不炸,说一声) */
async function resolveOptions(source: OptionsSource): Promise<readonly LaunchOption[] | undefined> {
const [domain, object] = source.ledger.split('.', 2);
if (domain === undefined || object === undefined || domain === '' || object === '') return undefined;
const address = { domain, object };
const described = await deps.ledger.describe(address).catch(() => undefined);
if (described === undefined) {
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 尚未物化`);
return undefined;
}
const columns = new Set(described.columns.map((c) => c.name));
if (!columns.has(source.value) || !columns.has(source.label)) {
warn(`[activity-ops] 启动参数候选读不到:${source.ledger} 没有列 ${source.value} / ${source.label}`);
return undefined;
}
const page = await deps.ledger
.query({ address, where: {}, orderBy: [{ field: source.label, dir: 'asc' }], limit: OPTIONS_CAP, offset: 0 })
.catch((error: unknown) => {
warn(`[activity-ops] 启动参数候选查询失败:${source.ledger} —— ${error instanceof Error ? error.message : String(error)}`);
return undefined;
});
if (page === undefined) return undefined;
/* 分组键(联动用):声明了、且候选表真有这一列才取 —— 列不在就当没声明(候选照出,只是不联动) */
const group = source.group !== undefined && columns.has(source.group.column) ? source.group : undefined;
if (source.group !== undefined && group === undefined) warn(`[activity-ops] 分组键读不到:${source.ledger} 没有列 ${source.group.column}`);
const names = group?.dict === undefined ? new Map<string, string>() : await groupLabels(group.dict);
return page.rows
.map((row) => {
const value = row[source.value];
const label = row[source.label];
const v = value === undefined || value === null ? '' : String(value);
const l = label === undefined || label === null || String(label) === '' ? v : String(label);
if (group === undefined) return { value: v, label: l };
const raw = row[group.column];
/* 那一列没值 = 「未标类型」:空串是它自己的一档,不是「没有分组键这回事」 */
const key = raw === undefined || raw === null ? '' : String(raw);
const name = names.get(key);
return { value: v, label: l, group: key, ...(name === undefined ? {} : { groupLabel: name }) };
})
.filter((o) => o.value !== '');
}
type InstanceCtx = { readonly names: ReadonlyMap<string, string>; readonly specs: readonly FieldSpec[] };
/** 一行 task → 状态裁定;非终态才需要翻记忆与 loop 运行态(判驳回) */
async function verdictOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<{ verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
const terminal = isTerminal(row.terminal) ? row.terminal : undefined;
if (terminal !== undefined) {
return { verdict: instanceStateOf({ terminal, reason: row.reason ?? undefined, runtime: row.runtime, usedSteps: row.usedSteps, lastVerdict: undefined, pending: undefined }), history: undefined };
}
const taskId = taskIdSchema.parse(row.id);
const [history, loop] = await Promise.all([deps.memory.history(taskId), deps.loopState.load(taskId)]);
const pending = loop?.pending;
return {
verdict: instanceStateOf({
terminal: undefined,
reason: row.reason ?? undefined,
runtime: row.runtime,
usedSteps: row.usedSteps,
lastVerdict: verdictFactOf(history, (id) => names.get(id)),
pending: pending === undefined ? undefined : { verdict: pending.verdict, comment: pending.comment },
}),
history,
};
}
async function inputTextOf(row: TaskRow, specs: readonly FieldSpec[]): Promise<string> {
const fromData = inputSummaryOf(row.data, specs);
if (fromData !== '') return fromData;
const comm = await deps.store.inboundCommOf(row.id);
return inputSummaryOf({}, [], comm?.subject !== undefined && comm.subject !== '' ? comm.subject : row.title);
}
async function outputTextOf(row: TaskRow, state: InstanceState, history: ReadonlyArray<HistoryEntry> | undefined): Promise<string | null> {
if (state !== 'completed') return null;
const taskId = taskIdSchema.parse(row.id);
const archived = await deps.files.listArchived(taskId);
const entries = history ?? (await deps.memory.history(taskId));
const fired = [...entries]
.reverse()
.find((e) => e.kind === 'release-verdict' && Array.isArray(e.detail?.['ports']))
?.detail?.['ports'];
const ports = Array.isArray(fired) ? fired.filter((p): p is string => typeof p === 'string') : [];
return outputSummaryOf(archived.map((f) => f.name), ports);
}
async function summarizeRow(row: TaskRow, ctx: InstanceCtx): Promise<{ summary: InstanceSummary; verdict: StateVerdict; history: ReadonlyArray<HistoryEntry> | undefined }> {
const { verdict, history } = await verdictOf(row, ctx.names);
return {
summary: {
id: row.id,
no: row.no,
state: verdict.state,
owner: staffRef(row.ownerId, ctx.names),
input: await inputTextOf(row, ctx.specs),
output: null,
startedAt: row.startedAt,
},
verdict,
history,
};
}
async function sourceOf(row: TaskRow, names: ReadonlyMap<string, string>): Promise<InstanceSource> {
if (row.workflowId !== null) {
const wf = await deps.store.workflowOf(row.workflowId);
return { kind: 'upstream', label: wf === undefined || wf.subject === '' ? '上游工作流' : `上游 · ${wf.subject}` };
}
const claim = await deps.store.launchOf(row.id);
if (claim !== undefined || row.origin === 'ops') {
const who = claim === undefined ? '运营端' : (names.get(claim.launchedBy) ?? NAME_FALLBACK);
return { kind: 'manual', label: `手动启动 · ${who}` };
}
const comm = await deps.store.inboundCommOf(row.id);
if (comm !== undefined) {
const kind = comm.channel === '' ? 'mail' : comm.channel;
return { kind, label: `${channelLabel(comm.channel)} · ${comm.party !== '' ? comm.party : comm.subject}` };
}
if (row.origin === 'testbed') return { kind: 'testbed', label: '测试台试跑' };
return { kind: 'system', label: '系统起单' };
}
/**
* 沙箱看板:最近一次**已放行**实例的落账快照(2026-08-27 用户定;在办的单不摆,行全从正式表圈)。
* 案号 = 声明的前缀 + 开案时刻(与模型铸主键那串同源);逐表读经 `describe → query → catch`,一张表读不了
* 不拖垮整屏(说一声);案号查无 → `board:state = missing`,没放行过的活动 → `none`,模版自己亮横幅。
*/
async function renderBoard(activityId: string, manifest: ManifestFace, html: string, spec: ViewData): Promise<string> {
const declared = manifest.declarations.ledger;
const instance = await deps.store.latestReleased(activityId);
if (instance === undefined) return renderView(html, viewScopeOf({ spec, sets: {}, declared, state: 'none' }));
const caseRef = `${spec.case.prefix}${caseStamp(instance.startedAt)}`;
const owner = await deps.org.getStaff(staffIdSchema.parse(instance.ownerId)).then((s) => s.name, () => '');
const read: ViewRead = async (address, where, orderBy) => {
const described = await deps.ledger.describe(address).catch(() => undefined);
if (described === undefined) return undefined;
const page = await deps.ledger.query({ address, where, orderBy: [...orderBy], limit: OPTIONS_CAP, offset: 0 }).catch(() => undefined);
return page?.rows;
};
const resolved = await resolveViewData(spec, { caseRef, read });
for (const name of new Set(resolved.skipped)) warn(`[activity-ops] 沙箱看板读不到 ${name}(${activityId} · ${caseRef}):表未物化或查询失败,这一块按空摆`);
const scope = viewScopeOf({
spec,
sets: resolved.sets,
declared,
instance: { no: instance.no, startedAt: instance.startedAt, finishedAt: instance.finishedAt, owner },
caseRef,
state: resolved.caseFound ? 'ready' : 'missing',
});
return renderView(html, scope);
}
async function activityOf(activityId: string): Promise<{ card: ActivityCard; manifest: ManifestFace } | undefined> {
const card = await deps.registry.get(activityId);
if (card === undefined) return undefined;
return { card, manifest: await deps.registry.manifestOf(activityId) };
}
async function detailRow(row: TaskRow, manifest: ManifestFace, names: ReadonlyMap<string, string>): Promise<InstanceDetail> {
const specs = fieldSpecsOf(manifest.contract.input.data);
const { summary, verdict, history } = await summarizeRow(row, { names, specs });
const taskId = taskIdSchema.parse(row.id);
const [entries, loop, source, archived] = await Promise.all([
history === undefined ? deps.memory.history(taskId) : Promise.resolve(history),
deps.loopState.load(taskId),
sourceOf(row, names),
deps.files.listArchived(taskId),
]);
const llm = llmOf(manifest);
const steps = followRes(
stepsOf({
items: loop?.parsed.items ?? [],
runtimeItems: loop?.runtime.items ?? [],
intents: new Map((loop?.model.steps ?? []).map((s) => [s.item, s.intent])),
history: entries.map((e) => ({ kind: e.kind, title: e.title, at: e.at })),
state: verdict.state,
ownerName: summary.owner.name,
llm,
}),
STEP_FOLLOW_RULE,
);
const startedMs = Date.parse(row.startedAt);
const finishedMs = row.finishedAt === null ? undefined : Date.parse(row.finishedAt);
const durationMs = finishedMs !== undefined && Number.isFinite(startedMs) && Number.isFinite(finishedMs) && finishedMs >= startedMs ? finishedMs - startedMs : null;
const inputFiles: InstanceFile[] = inputFilesOf(row.data, specs).map((f) => ({ index: f.index, name: f.name, contentType: f.contentType, size: f.size }));
const outputFiles: InstanceFile[] = archived.map((f, index) => ({ index, name: f.name, contentType: MIME_OF[f.format], size: f.size }));
// 详情里 `output` 是文件清单(文档 §6),清单上那一句摘要在详情里不再出现
const { input: content, output: _outputText, ...rest } = summary;
void _outputText;
return {
...rest,
activityId: row.activityId,
llm,
endedAt: row.finishedAt,
durationMs,
reason: verdict.reason,
input: { content, source, submittedAt: row.startedAt, files: inputFiles },
output: { files: outputFiles },
steps,
};
}
async function instanceDetail(activityId: string, instanceId: string): Promise<InstanceDetail | undefined> {
const found = await activityOf(activityId);
if (found === undefined) return undefined;
const row = await deps.store.getTask(activityId, instanceId);
if (row === undefined) return undefined;
const names = await namesOf();
return detailRow(row, found.manifest, names);
}
return {
async listActivities(status) {
const cards = await deps.registry.list();
const [profiles, counts, names] = await Promise.all([deps.store.profilesOf(), deps.store.countByActivity(), namesOf()]);
const out: ActivitySummary[] = [];
for (const card of cards) {
const manifest = await deps.registry.manifestOf(card.activityId);
const summary = summaryOf(card, manifest, profiles.get(card.activityId), counts.get(card.activityId) ?? 0, names);
if (status === undefined || summary.status === status) out.push(summary);
}
return out;
},
async getActivity(activityId) {
const card = await deps.registry.get(activityId);
return card === undefined ? undefined : detailOf(card);
},
async viewHtml(activityId) {
const found = await activityOf(activityId);
if (found === undefined) return { kind: 'no-activity' };
const template = found.manifest.assets.viewTemplates.find((v) => v.name === LEDGER_TEMPLATE);
if (template === undefined) return { kind: 'no-view' };
const html = template.data === undefined ? template.html : await renderBoard(activityId, found.manifest, template.html, template.data);
return { kind: 'html', html: injectHeightScript(html) };
},
async listInstances(activityId, query) {
const found = await activityOf(activityId);
if (found === undefined) return undefined;
const specs = fieldSpecsOf(found.manifest.contract.input.data);
const names = await namesOf();
const rows = await deps.store.listTasks(activityId);
const byId = new Map(rows.map((r) => [r.id, r]));
const summarized = await Promise.all(rows.map((row) => summarizeRow(row, { names, specs })));
const all = summarized.map((s) => s.summary);
const stats = statsOf(all);
const filtered = applyInstanceFilters(all, query);
const page = pageOf(filtered, query.page, query.size);
const items = await Promise.all(
page.map(async (summary) => {
const row = byId.get(summary.id);
const hit = summarized.find((s) => s.summary.id === summary.id);
const output = row === undefined || hit === undefined ? null : await outputTextOf(row, summary.state, hit.history);
return { ...summary, output };
}),
);
return { stats, total: filtered.length, page: query.page, size: query.size, items };
},
async launchParams(activityId) {
const found = await activityOf(activityId);
if (found === undefined) return undefined;
const params = await launchParamsOf(found.manifest.contract.input.data, resolveOptions);
return { activityId, version: versionOf(found.card), params };
},
async launch(activityId, input, by) {
const found = await activityOf(activityId);
if (found === undefined) return { kind: 'not-found' };
if (!found.card.enabled) return { kind: 'disabled' };
try {
const owner = await deps.org.getStaff(input.ownerId);
if (owner.state !== 'active') return { kind: 'owner-invalid', message: `责任人 ${owner.name} 已停用,换一位在职的人` };
} catch (error) {
if (error instanceof StaffNotFoundError) return { kind: 'owner-invalid', message: '责任人不在名册里' };
throw error;
}
const schema = found.manifest.contract.input.data;
const params = await launchParamsOf(schema, resolveOptions);
const checked = validateLaunchParams(params, input.params, fieldSpecsOf(schema));
if (!checked.ok) return { kind: 'invalid', issues: checked.issues };
const claim = await deps.store.claimLaunch({ key: input.idempotencyKey, activityId, ownerId: input.ownerId, launchedBy: by.id });
if (claim !== 'claimed') {
if (claim.activityId !== activityId) return { kind: 'busy', message: '这枚幂等键已用在别的活动上 —— 换一枚' };
if (claim.taskId !== null) {
const detail = await instanceDetail(activityId, claim.taskId);
return detail === undefined ? { kind: 'busy', message: '同一枚幂等键起的那一单已不在,换一枚键再启动' } : { kind: 'ok', detail, created: false };
}
const age = deps.clock.now().getTime() - Date.parse(claim.claimedAt);
if (!(age >= LAUNCH_CLAIM_STALE_MS)) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
const won = await deps.store.reclaimLaunch(input.idempotencyKey, LAUNCH_CLAIM_STALE_MS, input.ownerId, by.id);
if (!won) return { kind: 'busy', message: '同一枚幂等键的实例正在创建,稍后再试' };
}
const task = await deps.task.start({ activityId, ownerId: input.ownerId, title: found.manifest.identity.name, origin: 'ops', data: checked.data });
await deps.store.bindLaunch(input.idempotencyKey, task.id);
const detail = await instanceDetail(activityId, task.id);
return detail === undefined ? { kind: 'busy', message: '实例已建、暂时读不到,稍后刷新' } : { kind: 'ok', detail, created: true };
},
instanceDetail,
async instanceFile(activityId, instanceId, io, index) {
const found = await activityOf(activityId);
if (found === undefined) return { kind: 'no-instance' };
const row = await deps.store.getTask(activityId, instanceId);
if (row === undefined) return { kind: 'no-instance' };
const taskId = taskIdSchema.parse(row.id);
if (io === 'input') {
const file = inputFilesOf(row.data, fieldSpecsOf(found.manifest.contract.input.data))[index];
if (file === undefined) return { kind: 'no-file' };
const stored = await deps.files.readUpload(file.ref);
return stored === undefined ? { kind: 'no-file' } : { kind: 'file', name: stored.name, contentType: stored.contentType, bytes: stored.bytes };
}
const archived = (await deps.files.listArchived(taskId))[index];
if (archived === undefined) return { kind: 'no-file' };
const read = await deps.files.readArchived(taskId, archived.address, archived.key);
return read === undefined ? { kind: 'no-file' } : { kind: 'file', name: read.name, contentType: MIME_OF[read.format], bytes: read.bytes };
},
async setEnabled(activityId, enabled) {
const card = await deps.registry.get(activityId);
if (card === undefined) return undefined;
if (card.enabled !== enabled) await deps.registry.setEnabled(activityId, enabled);
const fresh = await deps.registry.get(activityId);
return fresh === undefined ? undefined : detailOf(fresh);
},
async terminate(activityId, instanceId, by) {
const found = await activityOf(activityId);
if (found === undefined) return { kind: 'not-found' };
const row = await deps.store.getTask(activityId, instanceId);
if (row === undefined) return { kind: 'not-found' };
if (row.terminal !== null) return { kind: 'terminal' };
await deps.task.control(taskIdSchema.parse(row.id), 'terminate', by.id, `由 ${by.name} 手动终止`);
const detail = await instanceDetail(activityId, instanceId);
return detail === undefined ? { kind: 'not-found' } : { kind: 'ok', detail };
},
};
}
+237
View File
@@ -0,0 +1,237 @@
/**
* 运营中心 · 活动模块的 app 层存取 —— 对 `sys.task` / `sys.activity_launch` / `sys.activity_profile` /
* `sys.workflow` / `sys.task_comm` 的 raw SQL 读(先例 `asset-archive.ts` / `upload-store.ts` / `workers/task-claim.ts`)。
*
* 为什么不在 task 模块加 `listByActivity`:清单按活动取、带序号与计数,是**运营面的读投影**,
* 不是任务运行时的能力;app 是唯一能同时认识多个模块的层(编码规范 §5)。写一律仍走 port。
* `seq` / `activity_launch` / `activity_profile` 都是手写迁移(0034 / 0035)建的,不在 Drizzle schema 里。
*/
import { sql } from 'drizzle-orm';
type Db = { execute(query: unknown): Promise<unknown> };
function rowsOf<T>(result: unknown): T[] {
if (Array.isArray(result)) return result as T[];
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? (rows as T[]) : [];
}
/**
* 时刻一律回 ISO 8601 UTC(契约 §0)。pg 的 timestamptz 在 raw `db.execute` 这一路可能回**文本**
* (`2026-08-27 16:55:14.383+08`,实测),Date 与文本两种都归一;认不出的原样交出去(不编)。
*/
function iso(value: unknown): string {
if (value instanceof Date) return value.toISOString();
const text = String(value);
const ms = Date.parse(text);
return Number.isFinite(ms) ? new Date(ms).toISOString() : text;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
/** `sys.task` 的一行,运营面要的那几列 */
export type TaskRow = {
readonly id: string;
/** 人读实例号 `INS-<seq>` */
readonly no: string;
readonly activityId: string;
readonly workflowId: string | null;
readonly origin: string | null;
readonly ownerId: string;
readonly title: string;
readonly runtime: string;
readonly terminal: string | null;
readonly reason: string | null;
readonly envelope: Record<string, unknown>;
readonly data: Record<string, unknown>;
readonly usedSteps: number;
readonly startedAt: string;
readonly finishedAt: string | null;
};
export type LaunchClaim = {
readonly key: string;
readonly activityId: string;
readonly ownerId: string;
readonly launchedBy: string;
readonly taskId: string | null;
readonly claimedAt: string;
};
export type ActivityProfile = {
readonly activityId: string;
readonly ownerId: string | null;
readonly description: string;
readonly ico: string | null;
readonly hue: string | null;
};
export type ActivityOpsStore = {
countByActivity(): Promise<ReadonlyMap<string, number>>;
listTasks(activityId: string): Promise<TaskRow[]>;
getTask(activityId: string, taskId: string): Promise<TaskRow | undefined>;
/** 最近一次已放行的实例(沙箱看板摆它):按放行时刻倒序,没放行过回 undefined */
latestReleased(activityId: string): Promise<TaskRow | undefined>;
/** 占键:插得进去回 `claimed`;键已在就回那一行(判与占一句 SQL,两台后端安全) */
claimLaunch(input: { key: string; activityId: string; ownerId: string; launchedBy: string }): Promise<'claimed' | LaunchClaim>;
/** 键在、单没建成(task_id 空)且已过 `staleMs` → 重占;抢到回 true */
reclaimLaunch(key: string, staleMs: number, ownerId: string, launchedBy: string): Promise<boolean>;
bindLaunch(key: string, taskId: string): Promise<void>;
launchOf(taskId: string): Promise<LaunchClaim | undefined>;
profilesOf(): Promise<ReadonlyMap<string, ActivityProfile>>;
upsertProfile(profile: ActivityProfile): Promise<void>;
workflowOf(workflowId: string): Promise<{ subject: string; source: Record<string, unknown> } | undefined>;
inboundCommOf(taskId: string): Promise<{ channel: string; party: string; subject: string } | undefined>;
};
type RawTask = {
id: string;
seq: unknown;
activity_id: string;
workflow_id: string | null;
origin: string | null;
owner_id: string;
title: string;
runtime: string;
terminal: string | null;
reason: string | null;
envelope: unknown;
input_data: unknown;
used_steps: unknown;
started_at: unknown;
finished_at: unknown;
};
function taskOf(r: RawTask): TaskRow {
return {
id: r.id,
no: `INS-${String(r.seq ?? '')}`,
activityId: r.activity_id,
workflowId: r.workflow_id,
origin: r.origin,
ownerId: r.owner_id,
title: r.title,
runtime: r.runtime,
terminal: r.terminal,
reason: r.reason,
envelope: record(r.envelope),
data: record(r.input_data),
usedSteps: Number(r.used_steps ?? 0),
startedAt: iso(r.started_at),
finishedAt: r.finished_at === null || r.finished_at === undefined ? null : iso(r.finished_at),
};
}
type RawClaim = { key: string; activity_id: string; owner_id: string; launched_by: string; task_id: string | null; claimed_at: unknown };
function claimOf(r: RawClaim): LaunchClaim {
return { key: r.key, activityId: r.activity_id, ownerId: r.owner_id, launchedBy: r.launched_by, taskId: r.task_id, claimedAt: iso(r.claimed_at) };
}
const TASK_COLUMNS = sql`id, seq, activity_id, workflow_id, origin, owner_id, title, runtime, terminal, reason, envelope, input_data, used_steps, started_at, finished_at`;
export function createActivityOpsStore(db: Db): ActivityOpsStore {
return {
async countByActivity() {
const rows = rowsOf<{ activity_id: string; n: unknown }>(
await db.execute(sql`select activity_id, count(*)::int as n from sys.task group by activity_id`),
);
return new Map(rows.map((r) => [r.activity_id, Number(r.n ?? 0)]));
},
async listTasks(activityId) {
const rows = rowsOf<RawTask>(
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} order by started_at desc, seq desc`),
);
return rows.map(taskOf);
},
async getTask(activityId, taskId) {
const rows = rowsOf<RawTask>(
await db.execute(sql`select ${TASK_COLUMNS} from sys.task where activity_id = ${activityId} and id = ${taskId} limit 1`),
);
const one = rows[0];
return one === undefined ? undefined : taskOf(one);
},
async latestReleased(activityId) {
const rows = rowsOf<RawTask>(
await db.execute(sql`
select ${TASK_COLUMNS} from sys.task
where activity_id = ${activityId} and terminal = 'released'
order by finished_at desc nulls last, started_at desc limit 1`),
);
const one = rows[0];
return one === undefined ? undefined : taskOf(one);
},
async claimLaunch({ key, activityId, ownerId, launchedBy }) {
const inserted = rowsOf<{ key: string }>(
await db.execute(sql`
insert into sys.activity_launch (key, activity_id, owner_id, launched_by)
values (${key}, ${activityId}, ${ownerId}, ${launchedBy})
on conflict (key) do nothing
returning key`),
);
if (inserted.length > 0) return 'claimed';
const existing = rowsOf<RawClaim>(
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where key = ${key} limit 1`),
)[0];
// 插不进去又读不到:另一台刚回滚 —— 当作它还在创建,让调用方按 busy 处置
return existing === undefined ? { key, activityId, ownerId, launchedBy, taskId: null, claimedAt: new Date(0).toISOString() } : claimOf(existing);
},
async reclaimLaunch(key, staleMs, ownerId, launchedBy) {
const rows = rowsOf<{ key: string }>(
await db.execute(sql`
update sys.activity_launch
set claimed_at = now(), owner_id = ${ownerId}, launched_by = ${launchedBy}
where key = ${key} and task_id is null and claimed_at < now() - (${staleMs} || ' milliseconds')::interval
returning key`),
);
return rows.length > 0;
},
async bindLaunch(key, taskId) {
await db.execute(sql`update sys.activity_launch set task_id = ${taskId} where key = ${key}`);
},
async launchOf(taskId) {
const one = rowsOf<RawClaim>(
await db.execute(sql`select key, activity_id, owner_id, launched_by, task_id, claimed_at from sys.activity_launch where task_id = ${taskId} limit 1`),
)[0];
return one === undefined ? undefined : claimOf(one);
},
async profilesOf() {
const rows = rowsOf<{ activity_id: string; owner_id: string | null; description: string; ico: string | null; hue: string | null }>(
await db.execute(sql`select activity_id, owner_id, description, ico, hue from sys.activity_profile`),
);
return new Map(
rows.map((r) => [r.activity_id, { activityId: r.activity_id, ownerId: r.owner_id, description: r.description ?? '', ico: r.ico, hue: r.hue }]),
);
},
async upsertProfile(p) {
await db.execute(sql`
insert into sys.activity_profile (activity_id, owner_id, description, ico, hue, updated_at)
values (${p.activityId}, ${p.ownerId}, ${p.description}, ${p.ico}, ${p.hue}, now())
on conflict (activity_id) do update
set owner_id = excluded.owner_id, description = excluded.description, ico = excluded.ico, hue = excluded.hue, updated_at = now()`);
},
async workflowOf(workflowId) {
const one = rowsOf<{ subject: string; source: unknown }>(await db.execute(sql`select subject, source from sys.workflow where id = ${workflowId} limit 1`))[0];
return one === undefined ? undefined : { subject: one.subject ?? '', source: record(one.source) };
},
async inboundCommOf(taskId) {
const one = rowsOf<{ channel: string; party: string; subject: string }>(
await db.execute(sql`select channel, party, subject from sys.task_comm where task_id = ${taskId} and direction = 'in' order by at limit 1`),
)[0];
return one === undefined ? undefined : { channel: one.channel ?? '', party: one.party ?? '', subject: one.subject ?? '' };
},
};
}
+114
View File
@@ -0,0 +1,114 @@
/**
* 清单上的一句话与检索 —— 输入摘要 / 输入文件 / 产出摘要 / 过滤 / 统计 / 分页,全是纯函数。
*/
import type { InstanceState, InstanceStats, InstanceSummary } from '@contracts';
import { INSTANCE_STATES } from '@contracts';
import type { FieldSpec } from './launch-params.ts';
export const SUMMARY_MAX = 80;
/** 压空白、截到一行能摆下的长度;截了才加省略号(加了等于说「后面还有」) */
export function clip(text: string, max = SUMMARY_MAX): string {
const one = text.replace(/\s+/g, ' ').trim();
return one.length > max ? `${one.slice(0, max - 1)}…` : one;
}
/**
* 输入一句话:清单标了 `x-control: textarea` 的那格最像「这单是什么」(客户需求 / 本轮说明),
* 没有就取最长的那个顶层字符串;都没有退回来件主题;再没有就空串。文件字段不算。
*/
export function inputSummaryOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[], fallback?: string): string {
const fileKeys = new Set(specs.filter((s) => s.format === 'file').map((s) => s.key));
for (const spec of specs) {
if (spec.control !== 'textarea') continue;
const value = data[spec.key];
if (typeof value === 'string' && value.trim() !== '') return clip(value);
}
let longest = '';
for (const [key, value] of Object.entries(data)) {
if (fileKeys.has(key) || typeof value !== 'string') continue;
if (value.trim().length > longest.trim().length) longest = value;
}
if (longest.trim() !== '') return clip(longest);
return fallback === undefined ? '' : clip(fallback);
}
export type InputFileRef = { readonly index: number; readonly name: string; readonly contentType: string; readonly size: number; readonly ref: string };
/** `format: 'file'` 的字段里那些 `{name, contentType, size, ref}`;`index` 跨字段连续编号 */
export function inputFilesOf(data: Readonly<Record<string, unknown>>, specs: readonly FieldSpec[]): InputFileRef[] {
const out: InputFileRef[] = [];
for (const spec of specs) {
if (spec.format !== 'file') continue;
const raw = data[spec.key];
const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
for (const one of list) {
if (typeof one !== 'object' || one === null) continue;
const file = one as Record<string, unknown>;
const ref = file['ref'];
if (typeof ref !== 'string' || ref === '') continue;
const size = file['size'];
out.push({
index: out.length,
name: typeof file['name'] === 'string' ? file['name'] : '未命名',
contentType: typeof file['contentType'] === 'string' ? file['contentType'] : 'application/octet-stream',
size: typeof size === 'number' && Number.isFinite(size) ? size : 0,
ref,
});
}
}
return out;
}
/** 产出一句话:有存档文件摆文件名;没有就摆发了哪几口;都没有 = null(前端摆 `-`) */
export function outputSummaryOf(archivedNames: readonly string[], firedPorts: readonly string[]): string | null {
if (archivedNames.length > 0) return clip(archivedNames.join('、'));
if (firedPorts.length > 0) return clip(`已发口:${firedPorts.join('、')}`);
return null;
}
export type InstanceQuery = {
readonly id?: string | undefined;
readonly ownerId?: string | undefined;
readonly state?: InstanceState | undefined;
readonly startedFrom?: string | undefined;
readonly startedTo?: string | undefined;
};
function dayBound(day: string, end: boolean): number | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
if (m === null) return undefined;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
// 服务进程的本地时区:单时区部署;「含两端整日」
return end ? new Date(y, mo - 1, d, 23, 59, 59, 999).getTime() : new Date(y, mo - 1, d, 0, 0, 0, 0).getTime();
}
/** 四条件 AND;`id` 模糊对实例号与任务 id 的子串(不分大小写) */
export function applyInstanceFilters(items: readonly InstanceSummary[], q: InstanceQuery): InstanceSummary[] {
const needle = q.id?.trim().toLowerCase() ?? '';
const from = q.startedFrom === undefined ? undefined : dayBound(q.startedFrom, false);
const to = q.startedTo === undefined ? undefined : dayBound(q.startedTo, true);
return items.filter((it) => {
if (needle !== '' && !it.no.toLowerCase().includes(needle) && !it.id.toLowerCase().includes(needle)) return false;
if (q.ownerId !== undefined && q.ownerId !== '' && it.owner.id !== q.ownerId) return false;
if (q.state !== undefined && it.state !== q.state) return false;
if (from !== undefined || to !== undefined) {
const t = Date.parse(it.startedAt);
if (!Number.isFinite(t)) return false;
if (from !== undefined && t < from) return false;
if (to !== undefined && t > to) return false;
}
return true;
});
}
export function statsOf(items: readonly InstanceSummary[]): InstanceStats {
const byState = Object.fromEntries(INSTANCE_STATES.map((s) => [s, 0])) as Record<InstanceState, number>;
for (const it of items) byState[it.state] += 1;
return { total: items.length, byState };
}
export function pageOf<T>(items: readonly T[], page: number, size: number): T[] {
const start = (Math.max(1, page) - 1) * Math.max(1, size);
return items.slice(start, start + Math.max(1, size));
}
@@ -0,0 +1,29 @@
/**
* L2 沙箱视图 —— 活动包里名为 `activity-ledger` 的视图模版(关键台账数据看板:最近一次已放行实例的落账快照,
* 行由基座按清单 `viewTemplates[].data` 的声明从正式表圈出,见 `view-scope.ts`)。
*
* 模版本身**无脚本**(安装静态审 `templateIsSafe` 拦着);而 iframe 是 `sandbox="allow-scripts"`
* 无同源,父页量不到它的高 —— 报高的脚本由服务端在**出口那一刻**注入,协议只有一条消息:
* `postMessage({ type: 'jcai:view-height', height }, '*')`(load + ResizeObserver + 1s 兜底)。
*/
import { ACTIVITY_VIEW_HEIGHT_MESSAGE } from '@contracts';
export const LEDGER_TEMPLATE = 'activity-ledger';
export function heightScript(): string {
return (
`<script>(function(){var T=${JSON.stringify(ACTIVITY_VIEW_HEIGHT_MESSAGE)};` +
'function h(){var b=document.body;return Math.max(document.documentElement.scrollHeight,b?b.scrollHeight:0)}' +
"function post(){try{window.parent.postMessage({type:T,height:h()},'*')}catch(e){}}" +
"window.addEventListener('load',post);" +
"if(typeof ResizeObserver!=='undefined'){new ResizeObserver(post).observe(document.documentElement)}" +
'setInterval(post,1000);post()})();</script>'
);
}
/** 插在 `</body>` 之前(没有就追加在末尾);模版原文一个字不改 */
export function injectHeightScript(html: string): string {
const script = heightScript();
const at = html.toLowerCase().lastIndexOf('</body>');
return at < 0 ? `${html}\n${script}` : `${html.slice(0, at)}${script}${html.slice(at)}`;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* 沙箱看板的取数与作用域(零 IO;读表经注入)—— 执行清单 `viewTemplates[].data` 那份声明。
*
* 分工:模版归活动、「哪几行是这一单的」归活动声明(案号前缀、哪张表按哪一列圈),这里只做两件机械的事:
* ① `resolveViewData`:按声明逐条求集合 —— `by.column ∈ values(by.from)`,`from` 只能指 `case`
* (案号本身)或前面已求出的 `<as>.<列>`;每个值单独查一次(正式表里历次运行的行并排躺着,
* `where` 只有等值),结果按 `from` 值的顺序拼接,再按 `not` 排除;
* ② `viewScopeOf`:把行集摊成渲染器要的扁平作用域 —— `<as>` 行数组、`<as>.<列>` 首行标量、
* `<as 去符号>_rows` 行数、`<as 去符号>_<列>_<值>` tally 计数(先铺 0,与引擎读数同一套命名习惯)、
* `case:*` 实例事实、`board:state` 三态(模版拿它亮横幅 / 藏区块)。
*
* 表未物化 / 查询失败 → 那个集合为空并记在 `skipped`(看板不因一张表 500);案号那一行查无 → `caseFound=false`
* (多半是起单时关了「落真库」,主数据只留在活动缓存),看板如实说「没落账」而不是摆一屏空表装没事。
*/
import type { ViewData, ViewDataRows } from '@contracts';
import { VIEW_DATA_CASE_REF } from '@contracts';
export type ViewRow = Readonly<Record<string, unknown>>;
export type ViewAddress = { readonly domain: string; readonly object: string };
export type ViewOrder = ReadonlyArray<{ readonly field: string; readonly dir: 'asc' | 'desc' }>;
/** 读一张正式表(等值过滤);表读不了回 undefined —— 由调用方决定怎么说 */
export type ViewRead = (address: ViewAddress, where: Readonly<Record<string, string>>, orderBy: ViewOrder) => Promise<readonly ViewRow[] | undefined>;
export type ViewSets = Readonly<Record<string, readonly ViewRow[]>>;
export type ResolvedViewData = { readonly sets: ViewSets; readonly caseFound: boolean; readonly skipped: readonly string[] };
export type BoardState = 'ready' | 'missing' | 'none';
export const BOARD_STATE_KEY = 'board:state';
export type DeclaredLedger = {
readonly domain: string;
readonly object: string;
readonly fields: ReadonlyArray<{ readonly name: string; readonly tally?: readonly string[] | undefined }>;
};
export type ViewInstance = {
readonly no: string;
readonly startedAt: string;
readonly finishedAt: string | null;
readonly owner: string;
};
export function addressOf(ledger: string): ViewAddress | undefined {
const at = ledger.indexOf('.');
if (at <= 0 || at === ledger.length - 1) return undefined;
return { domain: ledger.slice(0, at), object: ledger.slice(at + 1) };
}
/** `<as>.<列>` 从最后一个点拆(列名是标识符,不含点;`as` 本身可以带点) */
function refOf(from: string): { as: string; column: string } | undefined {
const at = from.lastIndexOf('.');
if (at <= 0 || at === from.length - 1) return undefined;
return { as: from.slice(0, at), column: from.slice(at + 1) };
}
/** 一列的值集:去空、转字符串、去重、保序 */
function valuesOf(rows: readonly ViewRow[], column: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const row of rows) {
const raw = row[column];
if (raw === undefined || raw === null || raw === '') continue;
const text = String(raw);
if (seen.has(text)) continue;
seen.add(text);
out.push(text);
}
return out;
}
function sourceValues(from: string, caseRef: string, sets: Record<string, readonly ViewRow[]>): string[] | undefined {
if (from === VIEW_DATA_CASE_REF) return [caseRef];
const ref = refOf(from);
if (ref === undefined) return undefined;
const rows = sets[ref.as];
return rows === undefined ? undefined : valuesOf(rows, ref.column);
}
export async function resolveViewData(spec: ViewData, io: { readonly caseRef: string; readonly read: ViewRead }): Promise<ResolvedViewData> {
const sets: Record<string, readonly ViewRow[]> = {};
const skipped: string[] = [];
const caseAddress = addressOf(spec.case.ledger);
const caseRows = caseAddress === undefined ? undefined : await io.read(caseAddress, { [spec.case.key]: io.caseRef }, []);
const caseFound = (caseRows?.length ?? 0) > 0;
if (caseRows === undefined) skipped.push(spec.case.ledger);
for (const entry of spec.rows) {
sets[entry.as] = await resolveEntry(entry, io.caseRef, sets, io.read, skipped);
}
return { sets, caseFound, skipped };
}
async function resolveEntry(entry: ViewDataRows, caseRef: string, sets: Record<string, readonly ViewRow[]>, read: ViewRead, skipped: string[]): Promise<readonly ViewRow[]> {
const address = addressOf(entry.ledger);
const values = sourceValues(entry.by.from, caseRef, sets);
if (address === undefined || values === undefined) {
skipped.push(entry.as);
return [];
}
if (values.length === 0) return [];
const orderBy = entry.orderBy ?? [];
const out: ViewRow[] = [];
for (const value of values) {
const rows = await read(address, { [entry.by.column]: value }, orderBy);
if (rows === undefined) {
skipped.push(entry.as);
return [];
}
out.push(...rows);
}
if (entry.not === undefined) return out;
const banned = new Set(sourceValues(entry.not.from, caseRef, sets) ?? []);
const notColumn = entry.not.column;
return out.filter((row) => {
const raw = row[notColumn];
return raw === undefined || raw === null || !banned.has(String(raw));
});
}
/** 与引擎读数同一套习惯:非字母数字一律下划线(`sub_eval.verdict` → `sub_eval_verdict`) */
export function slugOf(as: string): string {
return as.toLowerCase().replace(/[^a-z0-9]+/g, '_');
}
/** 屏上摆的时刻:本地、到分、带年(看板隔着日子看,「08-27 21:51」不够) */
export function localMinute(iso: string | null): string {
if (iso === null) return '';
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return '';
const two = (n: number): string => String(n).padStart(2, '0');
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
export function viewScopeOf(input: {
readonly spec: ViewData | undefined;
readonly sets: ViewSets;
readonly declared: ReadonlyArray<DeclaredLedger>;
readonly instance?: ViewInstance | undefined;
readonly caseRef?: string | undefined;
readonly state: BoardState;
}): Record<string, unknown> {
const scope: Record<string, unknown> = {
[BOARD_STATE_KEY]: input.state,
'case:instanceNo': input.instance?.no ?? '',
'case:openedAt': input.instance === undefined ? '' : localMinute(input.instance.startedAt),
'case:releasedAt': input.instance === undefined ? '' : localMinute(input.instance.finishedAt),
'case:owner': input.instance?.owner ?? '',
'case:ref': input.caseRef ?? '',
};
for (const entry of input.spec?.rows ?? []) {
const rows = input.sets[entry.as] ?? [];
const slug = slugOf(entry.as);
scope[entry.as] = rows;
scope[`${slug}_rows`] = rows.length;
const first = rows[0];
if (first !== undefined) for (const [column, value] of Object.entries(first)) scope[`${entry.as}.${column}`] = value;
const address = addressOf(entry.ledger);
const declaration = address === undefined ? undefined : input.declared.find((d) => d.domain === address.domain && d.object === address.object);
for (const field of declaration?.fields ?? []) {
if (field.tally === undefined) continue;
for (const value of field.tally) scope[`${slug}_${field.name}_${slugOf(value)}`] = 0;
for (const row of rows) {
const raw = row[field.name];
if (raw === undefined || raw === null || raw === '') continue;
const key = `${slug}_${field.name}_${slugOf(String(raw))}`;
scope[key] = Number(scope[key] ?? 0) + 1;
}
}
}
return scope;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 开案时刻的两种写法 —— **同一个源头,两处消费**:
* · `openedAtText`:`YYYY-MM-DD HH:mm:ss`(本地时区,给到秒),引擎注进 Agent 作用域的 `case:openedAt`,
* 说明书让模型「去掉分隔符」铸案号(E04 `round_ref` / W05 `eval_ref` / `request_id`);
* · `caseStamp`:上一串去掉分隔符 = 案号里那 14 位数字。运营中心沙箱看板要按案号从正式表圈出
* 「这一单的行」,必须与模型铸主键用的是同一串 —— 所以两边都从这里取,不各写一份。
* 时区取服务进程本地(引擎那份从来如此;两台后端一个库时得跑同一时区,否则案号就对不上)。
*/
const two = (n: number): string => String(n).padStart(2, '0');
export function openedAtText(iso: string): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return '';
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
}
/** `openedAtText` 去掉分隔符:`20260827215103` 这一形 */
export function caseStamp(iso: string): string {
return openedAtText(iso).replace(/\D/g, '');
}
+30 -20
View File
@@ -416,6 +416,11 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
const toolRegistry = createToolRegistry(db);
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
/*
* 差异评估的两枚比对工具已退役(2026-08-29,墓志铭见 `tools/first-party.ts`)——
* 规则改由 `part-substitution-assessment` 的 `S2_assess.auto` 配方现算,这里的
* `parameterLedger`(从台账取参数的只读口)随之一并撤,不再有第二处消费方。
*/
const toolRuntime = createToolRuntime(
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
httpToolHandler,
@@ -669,28 +674,33 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
},
},
cards: {
issueAcceptance: async (input) => {
/*
* **这条链的最后一关:默认是「传过去」,不是「丢掉」**(2026-09-01 第三次踩同一个坑改的结构)。
*
* 从前这儿是一个**手写字段清单**的显式对象:上游(契约 → submit → release)一路传下来的东西,
* 只要这里没被点到名就在这一行静默落地,编译不报、运行时无声。已经吃过三次:
* ① `fields` 被写死成 `[]` —— 验收报告只能看不能改(第 12 章③);
* ② `blocks` 漏了一行 —— 卡面正文块恒空;
* ③ `redoable` 漏了一行(2026-09-01 用户:「怎么还有重做??你没改上吗?」)——
* 装配层算对了、release 转对了、契约也声明了,到这儿被抹平,那颗钮照旧摆着。
* 两条注释就写在那几行旁边警告「少写一行就静默落地」,照样第三次。**措辞治不了的结构治**:
* 把 context 三样拆出去,**其余整包转交** —— 以后端口上新增一个可选字段,这里不改也到得了。
* 剩下的两处 `as never` 是类型收窄(品牌类型 / 结构宽松的块),不是丢值。
*/
issueAcceptance: async ({ taskId, ownerId, humanCallCap, blocks, checklist, ...draft }) => {
const issued = await humanTool.issueAcceptance(
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
{ taskId, ownerId, humanCallCap },
{
title: input.title,
basis: [...input.basis],
deliverable: input.deliverable as never,
criteria: [...input.criteria],
actions: input.actions.map((a) => ({ ...a })),
/*
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
* 验收报告于是只能看不能改(第 12 章③)。
*/
fields: (input.fields ?? []).map((f) => ({ ...f })) as never,
/*
* 正文块照传 —— 与 `fields` 同一个坑:这个显式对象是这条链的最后一关,
* 少写一行,上游传了一路的东西就在这里静默落地(卡面 blocks 恒空,踩过两次)。
*/
...(input.blocks === undefined || input.blocks.length === 0
? {}
: { blocks: input.blocks.map((b) => ({ ...b })) as never }),
/* **整包转交** —— 端口上新增一个可选字段,这里不改也到得了(见上面那段头注) */
...draft,
/* 下面几行不是「挑字段」,是**类型收窄**:只读转可变、品牌类型、结构宽松的块各要一次 */
basis: [...draft.basis],
deliverable: draft.deliverable as never,
criteria: [...draft.criteria],
actions: draft.actions.map((a) => ({ ...a })),
fields: (draft.fields ?? []).map((f) => ({ ...f })) as never,
...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }),
...(blocks === undefined || blocks.length === 0 ? {} : { blocks: blocks.map((b) => ({ ...b })) as never }),
},
);
return { id: issued.order.id };
+116
View File
@@ -0,0 +1,116 @@
/**
* 数据工单确认即入库(2026-08-27 用户定:「数据工单里面填的数据应该是确认这个数据工单后
* 这些数据就应该入库了」)。
*
* 从前草稿层通往正式表只有整单放行这一道口子:人在数据工单的件上把主数据建齐、按下确认,
* 那几张表照旧躺在 `ledger.__draft` 里,要等整条评估跑到 T_release 才并进库 ——
* 卡上明写着「确认后这批主数据即入库」,而系统里没有一行代码兑现它;试跑单(不落主数据)更是永远不落。
*
* 现在:确认落下那一刻,把**这张卡的提交闸盯着的那几张表**并进正式表(`ledger.releaseWrite` 的范围形)。
* 闸看哪几张表,落地就落哪几张 —— 同一份名单(卡上绑的 `blocks` > 卡自报的 `gateLedgers`),
* 不回落到必交付物:那几张是整单放行才该落的单据,不归这张卡管。
* 并过的行留在草稿层打记号(仍是本单写下的行,`mine:` / `_rows` / 任务视图照旧认它),整单放行时不再写。
*/
import type { TaskId, ViewBlock, WorkOrder } from '@contracts';
import type { LedgerService, ReleaseWriteResult } from '@modules/ledger';
/**
* 这张卡确认之后该入库的表 —— 只有**数据工单**(执行工单的 `variant:'data'`)才有;
* 名单与提交闸同源:绑了 `blocks` 看绑的,否则看 `gateLedgers`。两样都没有就是空 ——
* 这张卡没说自己是来建哪张表的,基座不替它猜(猜错就把不该落的东西落进了库)。
*/
export function dataOrderLedgers(order: WorkOrder): string[] {
if (order.mode !== 'execution' || order.variant !== 'data') return [];
const bound = (order.blocks ?? [])
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
.map((b) => b.ledger as string);
const own = bound.length > 0 ? bound : (order.gateLedgers ?? []);
const seen = new Set<string>();
const out: string[] = [];
for (const raw of own) {
const address = raw.trim();
if (!/^[a-z0-9_]+\.[a-z0-9_]+$/i.test(address) || seen.has(address)) continue;
seen.add(address);
out.push(address);
}
return out;
}
export type DataOrderLandingDeps = {
readonly ledger: Pick<LedgerService, 'releaseWrite'>;
/** 留痕:一条 `ledger-write`(工作日志与模型轨迹都读它) */
readonly memory: {
append(entry: { taskId: TaskId; kind: 'ledger-write'; title: string; detail: Record<string, unknown> }): Promise<unknown>;
};
/** 表的人读名(台账声明的 `label`);认不出退地址 */
readonly labelOf: (address: string) => Promise<string>;
/** 治理面(数据域)那一屏要跟着刷 —— 正式表刚多了几行 */
readonly touched?: () => void;
};
export type DataOrderLanding =
| { readonly landed: true; readonly result: ReleaseWriteResult; readonly tables: ReadonlyArray<{ address: string; label: string; rows: number }> }
| { readonly landed: false; readonly reason: 'not-data-order' | 'failed'; readonly error?: string };
/**
* 确认落下之后调它。失败不抛:人已经确认了,那一笔**记成办结**是对的;入库没成就把原话留在痕上,
* 那几行仍在草稿层,整单放行时照旧并进去(退回从前的时机,不丢数据)。
*/
export async function landDataOrder(deps: DataOrderLandingDeps, taskId: TaskId, order: WorkOrder): Promise<DataOrderLanding> {
const addresses = dataOrderLedgers(order);
if (addresses.length === 0) return { landed: false, reason: 'not-data-order' };
const only = addresses.map((a) => {
const [domain = '', object = ''] = a.split('.');
return { domain, object };
});
let result: ReleaseWriteResult;
try {
result = await deps.ledger.releaseWrite(taskId, { only, cause: `data-order:${order.id}` });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await deps.memory.append({
taskId,
kind: 'ledger-write',
title: `数据工单已确认,但这批数据没能入库:${message} —— 仍留在草稿层,放行时再并`,
detail: { workOrderId: order.id, tables: addresses, error: message },
});
return { landed: false, reason: 'failed', error: message };
}
const byTable = new Map<string, number>();
for (const row of result.merged) {
const at = `${row.domain}.${row.object}`;
byTable.set(at, (byTable.get(at) ?? 0) + 1);
}
const tables: Array<{ address: string; label: string; rows: number }> = [];
for (const address of addresses) {
tables.push({ address, label: await deps.labelOf(address), rows: byTable.get(address) ?? 0 });
}
const said = tables.map((t) => `${t.label} ${t.rows} 行`).join('、');
/*
* 一条痕,三种局面各说各的(界面不撒谎):
* · 落了 —— 点名哪几张表各几行,并给模型一句行动指南:这几行从此在库,别再写它们(在库的键会被当场拒);
* · 试跑挡下 —— 主数据只留在活动缓存,正式表一行没进;
* · 早已落过(同一张卡确认两次) —— 无事发生。
*/
const title =
result.mergedRows > 0
? `数据工单确认入库:${said}已进正式表 —— 这几行从此在库,别再往这几张表写它们(在库的键会被当场拒);要改由人在件上改`
: result.simulatedRows > 0
? `数据工单已确认(试跑:${said}只留在活动缓存,未落正式表)`
: `数据工单已确认(这批行早已入库,这次无事发生)`;
await deps.memory.append({
taskId,
kind: 'ledger-write',
title,
detail: {
workOrderId: order.id,
rows: result.mergedRows,
simulated: result.simulatedRows,
landedEarlier: result.landedEarlierRows,
tables: tables.map((t) => ({ address: t.address, rows: t.rows })),
},
});
if (result.mergedRows > 0) deps.touched?.();
return { landed: true, result, tables };
}
+2
View File
@@ -32,6 +32,8 @@ const RUN_TABLES = [
'sys.upload',
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
'sys.task_asset_file',
// 运营端启动的幂等键是跑出来的(它指着 task);活动档案(sys.activity_profile)是配置,留
'sys.activity_launch',
'sys.node_instance',
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
'sys.trace_span',
+167
View File
@@ -0,0 +1,167 @@
/**
* 运营中心 · 活动模块路由(`docs/API文档-活动模块.md`)。归属在契约的 access 上(全 ops),中间件统一判;
* 这里只做 zod 校验、调用用例、把结果翻成状态码。写路由的广播由 `/api/gov/*` 中间件自动发。
*/
import type { Context, Hono } from 'hono';
import type { ApiError } from '@contracts';
import {
activateOpsActivityRoute,
disableOpsActivityRoute,
getOpsActivityRoute,
getOpsInstanceRoute,
launchOpsInstanceRoute,
listOpsActivitiesRoute,
listOpsInstancesRoute,
opsActivityViewRoute,
opsInstanceFileRoute,
opsLaunchParamsRoute,
terminateOpsInstanceRoute,
} from '@contracts';
import type { AppEnv } from '../http-env.ts';
import { staffOf } from '../http-env.ts';
import type { ActivityOps } from '../activity-ops/service.ts';
export type ActivityRouteDeps = { readonly activityOps: ActivityOps };
function invalid(c: Context<AppEnv>, message: string, detail: unknown): Response {
const body: ApiError = { code: 'validation-failed', message, detail };
return c.json(body, 400);
}
function notFound(c: Context<AppEnv>, message: string): Response {
const body: ApiError = { code: 'not-found', message };
return c.json(body, 404);
}
function precondition(c: Context<AppEnv>, message: string): Response {
const body: ApiError = { code: 'precondition-failed', message };
return c.json(body, 412);
}
function unauthenticated(c: Context<AppEnv>): Response {
const body: ApiError = { code: 'unauthenticated', message: '这一步要知道是谁按的 —— 请求没带身份' };
return c.json(body, 401);
}
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
const text = await c.req.text();
if (text.trim() === '') return {};
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
export function registerActivityRoutes(app: Hono<AppEnv>, deps: ActivityRouteDeps): void {
const { activityOps } = deps;
// 通用 Context 上 param 是 string | undefined;路径模版里有这一段,空串只会落成 404
const activityId = (c: Context<AppEnv>): string => c.req.param('activityId') ?? '';
const instanceId = (c: Context<AppEnv>): string => c.req.param('instanceId') ?? '';
/* ── L1 ── */
app.get(listOpsActivitiesRoute.path, async (c) => {
const query = listOpsActivitiesRoute.query.safeParse(c.req.query());
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
return c.json({ activities: await activityOps.listActivities(query.data.status) });
});
/* ── L2 ── */
app.get(getOpsActivityRoute.path, async (c) => {
const detail = await activityOps.getActivity(activityId(c));
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
});
app.get(opsActivityViewRoute.path, async (c) => {
const view = await activityOps.viewHtml(activityId(c));
if (view.kind === 'no-activity') return notFound(c, `活动不存在:${activityId(c)}`);
if (view.kind === 'no-view') return notFound(c, '该活动没配视图');
return c.html(view.html, 200, { 'cache-control': 'no-store' });
});
app.get(listOpsInstancesRoute.path, async (c) => {
const query = listOpsInstancesRoute.query.safeParse(c.req.query());
if (!query.success) return invalid(c, '查询参数不合法', query.error.issues);
const list = await activityOps.listInstances(activityId(c), query.data);
return list === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(list);
});
/* ── 启动 ── */
app.get(opsLaunchParamsRoute.path, async (c) => {
const table = await activityOps.launchParams(activityId(c));
return table === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(table);
});
app.post(launchOpsInstanceRoute.path, async (c) => {
const parsed = launchOpsInstanceRoute.body.safeParse(await jsonBody(c));
if (!parsed.success) return invalid(c, '启动入参不合法', parsed.error.issues);
const me = staffOf(c);
if (me === undefined) return unauthenticated(c);
const outcome = await activityOps.launch(activityId(c), parsed.data, { id: me.id, name: me.name });
switch (outcome.kind) {
case 'ok':
return c.json(outcome.detail);
case 'not-found':
return notFound(c, `活动不存在:${activityId(c)}`);
case 'disabled':
return precondition(c, '活动已禁用,先激活再启动');
case 'invalid':
return invalid(c, `启动参数不合要求:${outcome.issues.map((i) => `${i.key} ${i.message}`).join('; ')}`, outcome.issues);
case 'owner-invalid':
return invalid(c, outcome.message, [{ key: 'ownerId', message: outcome.message }]);
case 'busy': {
const body: ApiError = { code: 'conflict', message: outcome.message };
return c.json(body, 409);
}
}
});
/* ── L3 ── */
app.get(getOpsInstanceRoute.path, async (c) => {
const detail = await activityOps.instanceDetail(activityId(c), instanceId(c));
return detail === undefined ? notFound(c, `实例不存在:${instanceId(c)}`) : c.json(detail);
});
app.get(opsInstanceFileRoute.path, async (c) => {
const params = opsInstanceFileRoute.params.safeParse({
activityId: activityId(c),
instanceId: instanceId(c),
io: c.req.param('io'),
index: c.req.param('index'),
});
if (!params.success) return invalid(c, '文件序号不合法', params.error.issues);
const file = await activityOps.instanceFile(params.data.activityId, params.data.instanceId, params.data.io, params.data.index);
if (file.kind === 'no-instance') return notFound(c, `实例不存在:${params.data.instanceId}`);
if (file.kind === 'no-file') return notFound(c, '这个文件不在了');
if (c.req.query('raw') === '1') {
return c.body(file.bytes.slice().buffer as ArrayBuffer, 200, {
'content-type': file.contentType,
// 文件名走 RFC 5987:中文名在各家浏览器上才落得对
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
});
}
return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') });
});
/* ── 禁用 / 激活 / 终止 ── */
const setEnabled = (enabled: boolean) => async (c: Context<AppEnv>) => {
const detail = await activityOps.setEnabled(activityId(c), enabled);
return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail);
};
app.post(disableOpsActivityRoute.path, setEnabled(false));
app.post(activateOpsActivityRoute.path, setEnabled(true));
app.post(terminateOpsInstanceRoute.path, async (c) => {
const me = staffOf(c);
if (me === undefined) return unauthenticated(c);
const outcome = await activityOps.terminate(activityId(c), instanceId(c), { id: me.id, name: me.name });
if (outcome.kind === 'not-found') return notFound(c, `实例不存在:${instanceId(c)}`);
if (outcome.kind === 'terminal') return precondition(c, '实例已终态,不能再终止');
return c.json(outcome.detail);
});
}
+64 -4
View File
@@ -29,8 +29,10 @@ import {
import { ReplyRejectedError } from '@modules/human-tool';
import { advanceOrder } from '@modules/process';
import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archive.ts';
import { landDataOrder } from '../data-order-landing.ts';
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
import { toFileDoc } from '../file-doc.ts';
import { afterSave } from '../save-wake.ts';
import { displayLog } from './log-lines.ts';
import { pulse } from '../workers/pulse.ts';
import type { Container } from '../container.ts';
@@ -43,7 +45,8 @@ function rowsOf<T>(result: unknown): T[] {
}
import { staffOf } from '../http-env.ts';
import type { AppEnv } from '../http-env.ts';
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
import { evaluateCondition } from '@sfeel';
import { coerceToColumn, distinctReadings, missingReadings, rowLabel, rowReadings, tallyReadings } from '../wiring/engines.ts';
import { orderChat } from '../chat-order.ts';
@@ -814,7 +817,41 @@ async function submitGate(
for (const draft of await container.ledger.taskDrafts(taskId)) {
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
}
return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
const base = emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
if (!base.ready) return base;
/*
* **条件档**(2026-08-31,W05「两颗料不是同一个芯片类型还能确认」逼出):缺格闸都过了,
* 发卡的人还声明了一条 S-FEEL 条件的,再按**全部声明表的基座读数**现算一遍 —— 为假就
* 不受理。读数从本单草稿现读(rows/missing/tally/distinct 同一套函数),没有 tick 滞后:
* 人刚在件上改完类型、点确认,算的就是改完之后的样子。
*/
return (
conditionGateOf(order.gateWhen, order.gateWhenSay, {
...rowReadings(mine, manifest.declarations.ledger),
...missingReadings(mine, manifest.declarations.ledger),
...tallyReadings(mine, manifest.declarations.ledger),
...distinctReadings(mine, manifest.declarations.ledger),
}) ?? base
);
}
/**
* 确认闸的条件档 —— S-FEEL 为假就交不了,拒回用发卡人写的那句话(display 与服务端同一段)。
* 条件只读基座读数(机检纪律)。表达式炸了按「不拦」:坏表达式装包时静态审就该拦
* (checkWhen 已审),运行时评估不出的条件不该成墙(「没有依据的时候不拦人」)。
*/
export function conditionGateOf(
when: string | undefined,
say: string | undefined,
readings: Readonly<Record<string, string | number>>,
): { ready: boolean; why: string; blanks: string[] } | undefined {
if (when === undefined || when.trim() === '') return undefined;
try {
if (evaluateCondition(when, readings)) return undefined;
} catch {
return undefined;
}
return { ready: false, why: say ?? '这张卡声明的确认条件还不成立 —— 回件上把它办对再来确认。', blanks: [] };
}
/**
@@ -1360,8 +1397,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
return c.json(body, 409);
}
const saved = await applyViewTableEdits(container, taskId, parsed.data);
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
if (saved.rows > 0) await container.views.repush(taskId);
// 数据变了,件跟着变;并叫醒任务 —— auto 步的配方这才有机会按最新的草稿铺格
await afterSave(container, taskId, saved.rows);
/*
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
@@ -1441,6 +1478,29 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
*/
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
/*
* **数据工单确认即入库**(2026-08-27 用户定):这张卡让人从零建一张主数据表,
* 确认落下那一刻,它的提交闸盯着的那几张表就并进正式表 —— 不等整单放行。
* 从前卡上写着「确认后即入库」而系统里没有一行代码兑现它;试跑单更是永远不落。
* 放在保存与回传都落定之后:并进去的就是人交卷那一刻件上的样子。失败不拦人(痕上说清,放行时再并)。
*/
await landDataOrder(
{
ledger: container.ledger,
memory: {
append: (entry) =>
container.memory.append({ ...entry, stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] } }),
},
labelOf: async (address) => {
const task = await container.task.get(outcome.taskId).catch(() => undefined);
const manifest = task === undefined ? undefined : await container.registry.manifestOf(task.activityId).catch(() => undefined);
return manifest?.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address)?.label ?? address;
},
touched: () => container.broadcaster.publish({ kind: 'system', id: 'ledger', reason: 'data-order-landed' }),
},
outcome.taskId,
outcome.order,
);
/*
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
+4 -1
View File
@@ -712,6 +712,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
cards: orders.map((o) => ({
id: o.id,
mode: o.mode,
...(o.mode === 'execution' && o.variant !== undefined ? { variant: o.variant } : {}),
...(o.mode === 'acceptance' && o.redoable !== undefined ? { redoable: o.redoable } : {}),
title: o.title,
done: o.done !== undefined,
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
@@ -1305,7 +1307,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 SQL */
const draftRows = rowsOf<{ domain: string; object: string; n: string; at: Date | string }>(
await container.db.execute(
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft group by domain, object`,
// 已在数据工单确认时入库的行(landed_at 非空)不算「在办草稿」—— 它们已经在正式行数里了
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft where landed_at is null group by domain, object`,
),
);
const drafts = new Map(
+49
View File
@@ -0,0 +1,49 @@
/**
* **件上按了保存之后**(2026-08-29,spec §5.4)。
*
* 两件事按序:
* ① 重推件 —— 数据变了,算出来的那些(红带缺几格 / 分布条 / 去向)得重新求值;
* ② **叫醒任务** —— auto 步的配方这才有机会按最新的草稿把该铺的行铺出来。
* 人挑了芯片类型、按了保存,参数格就在这一下之后出现。
*
* 从前只有两条回传路由调 `wake`;保存不是回传,于是「保存即铺格」永远等不到那一拍。
*
* **只在这一单有未答的卡时才叫醒**(2026-08-29 终审 F1)。`TaskService.wake` 对
* `awaiting-event` / `awaiting-human` 一律拨成 `running`,推进器随即推一拍并**记一步预算**。
* 而现有九个活动包一个 `auto` 都没有:人每按一次保存就烧掉一步预算、界面闪一次
* 「AI 正在推进这单」,那一拍走到 `cards.unanswered` 就原路返回;更糟的是任务本来
* `awaiting-human`(等人裁决)时被叫醒,那一拍结束会把状态写成 `awaiting-event` ——
* 从「等人办事」降级成「等外部回音」,而实际等的仍是人。**这是界面撒谎。**
* 而「保存即铺格」的场景恰恰都有卡在场(人正对着那张卡在件上填),验收态一点都不碰。
*
* **叫不醒不抬手**:保存本身是成功的,不该因为叫不醒把 200 变成 500 ——
* 下一次推进器扫到它照样会跑。但**不再静默**:它是「保存即铺格」的唯一触发器,
* 吞掉就等于人按了保存、格子没出现、系统一个字不说。
*/
import type { TaskId } from '@contracts';
function grumble(what: string, error: unknown): void {
const why = error instanceof Error ? error.message : String(error);
globalThis.process.stderr.write(`[save-wake] ${what}:${why}\n`);
}
export async function afterSave(
deps: {
readonly views: { repush(taskId: TaskId): Promise<unknown> };
readonly task: { wake(taskId: TaskId): Promise<unknown> };
/** 这一单正等人交回的卡(工单没办结的 + 对话卡没作答的)—— 用既有那条,不新造 */
readonly humanTool: { pendingCards(taskId: TaskId): Promise<ReadonlyArray<unknown>> };
},
taskId: TaskId,
rows: number,
): Promise<void> {
if (rows <= 0) return;
await deps.views.repush(taskId);
/* 问不出来就不叫醒:宁可少推一拍(推进器下一轮照样扫到它),也不平白烧一步预算 */
const pending = await deps.humanTool.pendingCards(taskId).catch((error: unknown) => {
grumble('问不出这一单有没有卡在等人,这次不叫醒', error);
return [];
});
if (pending.length === 0) return;
await deps.task.wake(taskId).catch((error: unknown) => grumble('叫醒失败,件已重推,等推进器下一轮', error));
}
+59 -11
View File
@@ -16,7 +16,7 @@ import { join, resolve } from 'node:path';
import { activityManifestSchema } from '@contracts';
import type { Container } from './container.ts';
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
import { createActivityOpsStore } from './activity-ops/store.ts';
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
/*
@@ -34,6 +34,33 @@ import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
*/
const REAL_PACKAGES = ['crm-relationship-coverage', 'brand-content-toolkit', 'part-substitution-assessment'] as const;
/**
* 运营侧活动档案(2026-08-27 用户定:负责人 / 说明 / 图标 / 色相另建 `sys.activity_profile`,活动包不载这些)。
* 按包目录名登记;负责人按**岗位**挑第一个在职的人 —— 员工 id 是 Keycloak 的 sub、首登即建,
* seed 拿不到真 sub,只能按岗位现查;**查不到就留空并说一声**,不编一个(界面不撒谎)。
* 重复播种是覆盖不是堆叠;运营面的改口另起(见 debt)。
*/
const ACTIVITY_PROFILES: Readonly<Record<(typeof REAL_PACKAGES)[number], { description: string; ico: string; hue: string; ownerPosition: string }>> = {
'crm-relationship-coverage': {
description: '按客户线月度 / 事件驱动地评估关系覆盖度,识别关键人缺口,编排经营计划并回收执行回执。',
ico: 'target',
hue: 'blue',
ownerPosition: 'BD',
},
'brand-content-toolkit': {
description: '受理品牌形象与销售工具包的内容需求,产出初稿交审核,发布后入品牌内容库。',
ico: 'palette',
hue: 'amber',
ownerPosition: 'OP',
},
'part-substitution-assessment': {
description: '对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。',
ico: 'git-compare',
hue: 'teal',
ownerPosition: 'FAE',
},
};
/**
* 活动包根目录。
*
@@ -68,16 +95,6 @@ export async function installRealActivities(container: Container): Promise<void>
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
);
/*
* **纯工具随活动一起注册**(2026-08-26):从前工具契约只在 db:seed 里注册,只跑 activities:install 的
* 开发机上,活动清单声明了工具、白名单也发了,`sys.tool` 里却没有那件工具 —— 第一次跑就撞「工具不存在」。
* 「安装即用」包含它声明过的纯工具能调得动。register 按 name+version 幂等,重复装是 no-op。
*/
for (const contract of W05_TOOLS) {
const verdict = await container.toolbox.register({ contract, samples: [W05_TOOL_SAMPLE] });
if (!verdict.accepted) log(` 工具 ${contract.name} 未过两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`);
}
const root = activitiesRoot();
for (const dir of REAL_PACKAGES) {
@@ -122,6 +139,18 @@ export async function installRealActivities(container: Container): Promise<void>
*/
const declared = parsed.data.declarations.tools.map((t) => t.name);
await container.toolbox.setAllowlist(parsed.data.identity.activityId, declared);
await seedProfile(container, dir, parsed.data.identity.activityId, log);
/*
* **同版重装不许说「已装」**(2026-08-27,一天里静默丢了两次):两审过了、版本已在库,
* 库里一个字没动 —— 这一句话就是人判断「改动上没上」的唯一依据,说错就是误导。
*/
if (!verdict.stored) {
log(
` 活动包已是这一版,未重装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
' —— 改了内容要升 packageVersion 再装(同版重装是 no-op,库里仍是先前那份)',
);
continue;
}
log(
` 活动包已装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
(declared.length === 0 ? '' : ` · 已授权工具 ${declared.length} 件`) +
@@ -155,3 +184,22 @@ export async function installRealActivities(container: Container): Promise<void>
}
log(` 样例主数据已就位:${MASTER_SAMPLE_ROWS.length} 行(客户 / 项目 / 联系人 / 料号)`);
}
/** 运营侧档案落库:说明 / 图标 / 色相照表,负责人按岗位现查(取不到留空,如实报) */
async function seedProfile(container: Container, dir: (typeof REAL_PACKAGES)[number], activityId: string, log: (line: string) => void): Promise<void> {
const spec = ACTIVITY_PROFILES[dir];
const candidates = await container.org.staffOfPosition(spec.ownerPosition).catch(() => []);
const owner = candidates[0];
await createActivityOpsStore(container.db).upsertProfile({
activityId,
ownerId: owner === undefined ? null : owner.id,
description: spec.description,
ico: spec.ico,
hue: spec.hue,
});
log(
owner === undefined
? ` 活动档案已记(${activityId}):负责人留空 —— 岗位 ${spec.ownerPosition} 当前没有在职的人`
: ` 活动档案已记(${activityId}):负责人 ${owner.name}(岗位 ${spec.ownerPosition})`,
);
}
+73 -14
View File
@@ -152,6 +152,27 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
`normalized_parameter_set` 的数据类型本就写的是 `Structure`。物理列 `parts.profile.
normalized_parameter_set` 仍留在基线里(不删列),只是活动清单不再声明它、不再写它。
老的 market.part(主键 pn)不复用,记 debt 待并。 */
master('parts', 'chip_type', '芯片类型', [
/*
* **整数自增主键**:人在件上「+新建类型」时这一格留空,由基座按「现有最大 + 1」续号
* (与 I01 的序号主键同一条路)。**空着的自增列不是身份线索,是「给我续个号」。**
*/
f('type_id', 'integer', '类型ID', true),
f('type_name', 'text', '类型名称'),
f('description', 'text', '类型说明'),
]),
master('parts', 'type_param', '芯片类型核心参数字段', [
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', '参考单位'),
]),
master('parts', 'profile', '芯片主数据(P-08)', [
f('internal_pn', 'text', '内部料号', true),
f('customer_pn', 'text', '客户料号'),
@@ -167,6 +188,7 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
f('datasheet_version', 'text', 'Datasheet 版本'),
f('application_scenarios', 'text', '适配应用场景'),
f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'),
f('chip_type_id', 'integer', '芯片类型'),
]),
/* P-08 子表:标准化参数集,一行一参数,按 internal_pn 归属主记录(《统一方案》§六:重复明细用子表) */
master('parts', 'parameter', '标准化参数集(P-08 子表)', [
@@ -475,6 +497,23 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
review_note: '',
},
},
/* 芯片类型字典 —— 字段清单挂在类型上,下面 parts.profile 的 chip_type_id 要指向这里的 type_id,故字典先播。 */
{ 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: '℃' } },
/*
* ── SS02 差异评估的样例料号(2026-08-26,2026-08-27 参数集改子表)──
* 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC
@@ -487,6 +526,7 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
key: { internal_pn: 'LM2596S-5.0' },
values: {
internal_pn: 'LM2596S-5.0',
chip_type_id: 1,
brand_name: 'TI',
manufacturer_name: 'Texas Instruments',
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
@@ -499,18 +539,19 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
application_scenarios: '工业电源;消费电源',
},
},
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '客户输入上限 40V' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard', param_note: '固定版' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', param_note: '客户输入上限 40V' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', param_note: '固定版' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', param_note: '满载能力' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', param_note: '影响外围电感/电容' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', param_note: '待机功耗' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', param_note: '工业温域' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', param_note: 'ON/OFF 引脚阈值' } },
{
address: { domain: 'parts', object: 'profile' },
key: { internal_pn: 'LM2596S-ADJ' },
values: {
internal_pn: 'LM2596S-ADJ',
chip_type_id: 1,
brand_name: 'TI',
manufacturer_name: 'Texas Instruments',
specification_description: '降压型 DCDC 稳压器,3A,可调输出 1.23~37V,150kHz(LM2596 同族)',
@@ -522,13 +563,31 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
application_scenarios: '工业电源;消费电源',
},
},
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '与固定版同' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard', param_note: '可调版,靠外围反馈网络定' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', param_note: '与固定版同' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', param_note: '可调版,靠外围反馈网络定' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', param_note: '满载能力' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', param_note: '影响外围电感/电容' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', param_note: '待机功耗' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', param_note: '工业温域' } },
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', param_note: 'ON/OFF 引脚阈值' } },
/*
* **AMS1117-3.3**(LDO 类型,chip_type_id: 2)—— 「两颗料类型不一致 → 硬拦」那条路的
* 唯一素材:不播这颗,「原 PN 是 DC-DC、候选是 LDO,字段清单对不上」这条判断就没有真数据可用。
*/
{
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: '' } },
{
address: { domain: 'parts', object: 'document' },
key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' },
+2 -3
View File
@@ -12,7 +12,6 @@ import type { Container } from './container.ts';
import { installRealActivities } from './seed-activities.ts';
import { installSlots } from './seed-mvp-slots.ts';
import { seedMvpFlow } from './seed-mvp-flow.ts';
import { W05_TOOLS } from './tools-w05.ts';
/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */
const HISTORY_TOOL = {
@@ -329,7 +328,7 @@ export async function seedMvp(container: Container): Promise<void> {
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
/* ② 功能工具注册两审 + 逐槽位白名单 */
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL, ...W05_TOOLS]) {
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) {
const verdict = await container.toolbox.register({
contract,
samples: [
@@ -360,7 +359,7 @@ export async function seedMvp(container: Container): Promise<void> {
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
await container.toolbox.setAllowlist(activityId, tools);
}
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 6,逐活动授权)');
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)');
/*
* ③ 活动装入 —— 两批:
+27
View File
@@ -30,6 +30,12 @@ import { registerGovernanceRoutes } from './routes/governance.ts';
import { registerIngestRoutes } from './routes/ingest.ts';
import { registerOrgDirectoryRoutes } from './routes/org-directory.ts';
import { registerMailRoutes } from './routes/mail.ts';
import { registerActivityRoutes } from './routes/activities.ts';
import { createActivityOps } from './activity-ops/service.ts';
import { createActivityOpsStore } from './activity-ops/store.ts';
import { createLoopStateStore } from '@modules/loop-engine';
import { readUpload } from './upload-store.ts';
import { listArchived, readArchived } from './asset-archive.ts';
import { registerStreamRoute } from './routes/stream.ts';
import { registerWorkbenchRoutes } from './routes/workbench.ts';
@@ -123,6 +129,27 @@ export function createServer(container: Container): Hono<AppEnv> {
registerRegistrationRoutes(app, container);
registerGovernanceOpsRoutes(app, container);
registerMailRoutes(app, container);
/*
* 运营中心 · 活动模块(2026-08-27):跨模块的读投影 + 编排住 app 层,依赖只取用到的那几面;
* 写走既有 port(task.start / task.control / registry.setEnabled)。
*/
registerActivityRoutes(app, {
activityOps: createActivityOps({
registry: container.registry,
task: container.task,
org: container.org,
memory: container.memory,
ledger: container.ledger,
loopState: createLoopStateStore(container.db),
store: createActivityOpsStore(container.db),
files: {
readUpload: (ref) => readUpload(container.db, ref),
listArchived: (taskId) => listArchived(container.db, taskId),
readArchived: (taskId, address, key) => readArchived(container.db, taskId, address, key),
},
clock: systemClock,
}),
});
registerIngestRoutes(app, container);
registerStreamRoute(app, container.broadcaster);
-87
View File
@@ -1,87 +0,0 @@
/**
* ACT-WORK-05 差异评估的两枚纯工具契约(活动私有作用域)。
* 两处消费:`seed-mvp.ts`(db:seed 全套播种)与 `seed-activities.ts`(activities:install 装活动时顺带注册 ——
* 「安装即用」包含它声明过的纯工具能调得动;从前 E04 的工具只在 db:seed 里注册,开发机上只跑
* activities:install 的人第一次跑活动就撞「工具不存在」)。
*/
const PURE = {
effect: 'read' as const,
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
idempotent: true,
timeoutMs: 5_000,
retries: 0,
connectionKeys: [] as string[],
pure: true,
ownerActivityId: 'part-substitution-assessment',
};
/** 逐参数比对 —— 出参键与台账列同名(difference_status / risk_level),模型照抄 */
export const COMPARE_PARAMETERS_TOOL = {
...PURE,
name: 'part-substitution-assessment.compare-parameters',
displayName: '替代料逐参数比对',
version: '1.0.0',
description:
'按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' +
'compare_result、risk_level 与各候选的计数。一次可比一批候选(candidates 数组),每对参数可带客户约束 requirement(如 ">= 40")与证据引用 evidenceRef;' +
'硬约束没有证据引用不判 match。规则版本 R-2026.07。',
input: {
type: 'object',
properties: {
candidates: {
type: 'array',
description: '[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}]',
},
},
},
output: {
type: 'object',
properties: {
ruleVersion: { type: 'string' },
results: {
type: 'array',
description: '[{candidateInternalPn, items:[{parameterName, unit, constraintType, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]',
},
},
},
};
/** 推荐定级 —— 出参 recommendationLevel / riskLevel / reason 直接落 P-11 */
export const RECOMMEND_TOOL = {
...PURE,
name: 'part-substitution-assessment.recommend',
displayName: '替代推荐定级',
version: '1.0.0',
description:
'按每个候选的比对计数定推荐等级:硬约束 mismatch>0 → not_recommended;unknown>0 → conditional;' +
'只有可调配成(configurable)→ alternate;其余 conditional → conditional;全 match → recommended。',
input: {
type: 'object',
properties: {
candidates: { type: 'array', description: '[{candidateInternalPn, counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]' },
},
},
output: {
type: 'object',
properties: {
ruleVersion: { type: 'string' },
results: { type: 'array', description: '[{candidateInternalPn, recommendationLevel, riskLevel, reason}]' },
},
},
};
export const W05_TOOLS = [COMPARE_PARAMETERS_TOOL, RECOMMEND_TOOL] as const;
/** 试运行审的样例(两件共用一份:并集覆盖各家入参) */
export const W05_TOOL_SAMPLE = {
input: {
candidates: [
{
candidateInternalPn: 'XL2596S-5.0',
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', unit: 'V', constraintType: 'hard', evidenceRef: 'P-08' }],
counts: { match: 1, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 },
},
],
},
expectShape: { type: 'object' },
};
+15 -157
View File
@@ -332,164 +332,24 @@ const slotPlaceholder: ToolHandler = async ({ input }) => {
};
};
/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)─────────────────
/* ── ACT-WORK-05 差异评估:逐参数比对与推荐定级 —— 已退役(2026-08-29)──────────
*
* 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份;
* 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。
* 【两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`(逐参数四态判定:
* MATCH / MISMATCH / UNKNOWN / CONDITIONAL,按硬/软约束与客户需求边界比对原 PN 与候选料)
* 与 `part-substitution-assessment.recommend`(按四态命中条数定推荐等级:推荐 / 条件推荐 /
* 备选 / 不推荐)曾经跑在这里,与活动包 manifest 里冻结的 D01(逐参数四态)/ D02(推荐等级)
* 两张 DMN 是同一份规则的两处真源。
*
* 比对语义(按硬/软约束):
* equal / covers(候选范围 ⊇ 原范围) → match,低风险;
* configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围);
* narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险;
* 任一侧未披露 → unknown 中风险;
* 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH);
* requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定是**整个活动唯一一次
* 模型调用**(`case-model.ts` 的 `S2_assess.auto` 用 `analyze` 动词,系统组装入参、模型只答
* 判断,不转述台账里已有的值),推荐等级是**能算的事**,由同一步 `do` 链里的投影按
* spec §3.5 的 S-FEEL when 链现算(`judgement_class` / `recommendation_level` 两条合成列)。
* `D01` / `D02` 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 或一枚工具,就是同一件事
* 两处各存一份,改一处就分叉而没人看得出以哪份为准。
*
* 完整实现见 git 历史(本文件此段落之前的版本);规则表见活动包 spec §3.5,
* 求值逻辑见 `activities/part-substitution-assessment/src/case-model.ts`。
*/
export const SUBSTITUTION_RULE_VERSION = 'R-2026.07';
type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' };
type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown';
type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional';
type RiskLevel = 'low' | 'medium' | 'high';
type ParamPair = {
parameterName: string;
originalValue: string;
candidateValue: string;
unit?: string;
constraintType: 'hard' | 'soft';
/**
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
*/
requirement?: string | null;
evidenceRef?: string | null;
};
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
function text(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
const NUM = '[-+]?\\d+(?:\\.\\d+)?';
const RANGE_RE = new RegExp(`^(${NUM})\\s*(?:~|~|-|–|—|至|to)\\s*(${NUM})`, 'i');
const NUM_RE = new RegExp(`^(${NUM})`);
export function parseParamValue(raw: unknown): Parsed {
const s = String(raw ?? '').trim().replace(/\s+/g, ' ');
if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' };
const r = RANGE_RE.exec(s);
if (r !== null) {
const lo = Number(r[1]);
const hi = Number(r[2]);
if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
}
const n = NUM_RE.exec(s);
if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) };
return { kind: 'text', value: s.toLowerCase() };
}
function compareParsed(o: Parsed, c: Parsed): CompareResult {
if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown';
if (o.kind === 'range' && c.kind === 'range') {
if (c.lo === o.lo && c.hi === o.hi) return 'equal';
if (c.lo <= o.lo && c.hi >= o.hi) return 'covers';
if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower';
return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial';
}
if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs';
if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs';
if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs';
if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs';
return 'differs';
}
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
const s = text(requirement);
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
const hi = c.kind === 'range' ? c.hi : c.value;
const lo = c.kind === 'range' ? c.lo : c.value;
if (ge !== null) return hi >= Number(ge[1]);
if (le !== null) return lo <= Number(le[1]);
const req = parseParamValue(s);
if (req.kind === 'range') return lo <= req.lo && hi >= req.hi;
if (req.kind === 'number') return lo <= req.value && hi >= req.value;
return undefined;
}
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
function compareLifecycle(o: string, c: string): CompareResult | undefined {
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
if (ro === undefined || rc === undefined) return undefined;
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
}
function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } {
const o = parseParamValue(p.originalValue);
const c = parseParamValue(p.candidateValue);
const hard = p.constraintType === 'hard';
let compareResult = compareParsed(o, c);
if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult;
const req = satisfiesRequirement(p.requirement, c);
if (req === false) {
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
}
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
if (compareResult === 'equal' || compareResult === 'covers') {
if (hard && text(p.evidenceRef) === '') {
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
}
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
}
if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' };
if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' };
compareResult = compareResult === 'narrower' ? 'narrower' : 'differs';
return hard
? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' }
: { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' };
}
const compareParameters: ToolHandler = async ({ input }) => {
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; pairs: ParamPair[] }>) : [];
const results = candidates.map((cand) => {
const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
const items = (Array.isArray(cand.pairs) ? cand.pairs : []).map((p) => {
const j = judgePair(p);
counts[j.differenceStatus] += 1;
if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1;
if (j.compareResult === 'configurable') counts.configurable += 1;
return { parameterName: p.parameterName, unit: p.unit ?? '', constraintType: p.constraintType, ...j };
});
return { candidateInternalPn: cand.candidateInternalPn, items, counts };
});
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
};
/**
* 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环);
* 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。
*/
const recommend: ToolHandler = async ({ input }) => {
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial<CompareCounts> }>) : [];
const results = candidates.map((cand) => {
const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0);
if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` };
if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` };
if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` };
if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` };
return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` };
});
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
};
export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> = [
['crm.history-price-read', historyPriceRead],
@@ -502,6 +362,4 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
['slot.placeholder', slotPlaceholder],
['crm-relationship-coverage.recompute-score', recomputeScore],
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
['part-substitution-assessment.compare-parameters', compareParameters],
['part-substitution-assessment.recommend', recommend],
];
@@ -0,0 +1,61 @@
/**
* `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。
*
* 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。
* 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 ——
* 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。
*
* **用量必须交回去记账**(2026-08-29 终审 C1):`createModelGateway` 自己不做
* `addConsumption`,计量全靠调用方给的 `onUsage` —— agent 那两条路都接了,
* 只有这一条从前接的是**裸网关**:配方里 analyze 烧的 token 一个数都不计入活动预算,
* 时间轴上也没有模型 span(那要 `tracedGateway`,由装配层决定传谁进来)。
* spec §5.2 写的是「走现有模型网关(四元绑定、凭证隔离、录制回放、**用量计入预算**)」——
* 少了这一条,那句话就只是文档里的一厢情愿。
*
* **两次都要记**:重问那一次照样烧真钱,漏记等于「失败的调用不要钱」。
*/
import type { ModelGateway } from '@modules/llm';
import type { AnalyzeGateway } from '@modules/loop-engine';
import type { TaskId } from '@contracts';
const RETRY_HINT =
'上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。';
export function createAnalyzeGateway(
llm: Pick<ModelGateway, 'generate'>,
/** 用量回账 —— 与 `engines.ts` 里 agent 那两条路的 `onUsage` 同一个函数 */
onUsage?: (taskId: TaskId, tokens: number) => Promise<void>,
): AnalyzeGateway {
return {
async run(input) {
const prompt = JSON.stringify(input.payload, null, 2);
const call = {
model: input.model,
system: input.instruction,
prompt,
/* 判断归判定表与规则,模型只做归类 —— 隐藏推理按秒计,这一档一律关 */
reasoning: 'off' as const,
temperature: 0.2,
outputSchema: input.outputSchema as Record<string, unknown>,
taskId: input.taskId,
label: input.label,
};
/* 记账**不吞异常**:记不上账 = 预算这道闸失效,宁可当场停下由配方留痕交人,也不要一单无声无息烧穿 */
const bill = async (usage: { inputTokens: number; outputTokens: number }): Promise<void> => {
if (onUsage === undefined) return;
await onUsage(input.taskId, usage.inputTokens + usage.outputTokens);
};
const first = await llm.generate(call);
await bill(first.usage);
if (typeof first.object === 'object' && first.object !== null) {
return { object: first.object as Record<string, unknown> };
}
const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` });
await bill(second.usage);
if (typeof second.object === 'object' && second.object !== null) {
return { object: second.object as Record<string, unknown> };
}
throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
},
};
}
+307 -9
View File
@@ -26,6 +26,7 @@ import { idempotencyKey } from '@modules/release';
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
import type { LoopEngineService } from '@modules/loop-engine';
import { createAnalyzeGateway } from './analyze-gateway.ts';
import { createSkillEngineService } from '@modules/skill-engine';
import { createTaskService, createTaskStore } from '@modules/task';
import type { TaskService } from '@modules/task';
@@ -38,6 +39,7 @@ import type { TriggerService } from '@modules/trigger';
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
import type { StaffId, TaskId } from '@contracts';
import { cardIdSchema, staffIdSchema } from '@contracts';
import { openedAtText } from '../case-stamp.ts';
import { archiveAssetFiles } from '../asset-archive.ts';
import { createAgentRunner } from '../agent-runner.ts';
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
@@ -190,6 +192,135 @@ export function localStamp(iso: string): string {
return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
}
/**
* 「只建不改」的表:这一笔写的键是不是已经在正式表里了 —— 在就回一句拒回的话(带指路),不在回 undefined。
* 纯函数:正式表的行由调用方查好递进来,这里只比键。
*/
export function createOnlyViolation(
target: { readonly label: string; readonly keys: ReadonlyArray<string> },
key: Readonly<Record<string, string | number>>,
formalRows: ReadonlyArray<Record<string, unknown>>,
/** 本单经数据工单确认**已入库**的行的键(2026-08-27)—— 它们也在正式表里,但拒回要说另一句话 */
landedKeys: ReadonlyArray<Record<string, unknown>> = [],
): string | undefined {
const same = (row: Record<string, unknown>) => target.keys.every((k) => String(row[k] ?? '') === String(key[k] ?? ''));
if (!formalRows.some(same)) return undefined;
const shown = target.keys.map((k) => `${k}=${String(key[k] ?? '')}`).join('、');
/*
* 这一行是**本单自己**在数据工单里建好、人确认后入库的 —— 指它去 drop 就错了:
* drop 撤的是草稿层那一笔,正式表一个字不动,撤了只会让本单的读面(`mine:` / `_rows`)少一行、哨兵开不了。
* 该说的是:它已经建好了,别再写;要改由人在件上改。
*/
if (landedKeys.some(same)) {
return (
`${target.label}里「${shown}」这一行是本单在数据工单里建好、人确认后已入库的 —— 建好了就别再写它;` +
'值要改由人在件上改(件上保存直落草稿层),你这一笔不用发,接着办下一件事。'
);
}
return (
`${target.label}里已经有「${shown}」这一行 —— 这张表在本单只许**新建库里没有的记录**(数据工单是从零建主数据用的),` +
'在库的料号不用建、也不许改:别再写它;要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉,免得件上多出一行要人填的东西。'
);
}
/**
* 决策卡的**面孔与人工验收清单**:调用方递了就用它的,没递才退回剧本声明的(按 itemId 查到的那一步)。
*
* 纯函数:剧本那一步由调用方查好递进来,这里只挑值。
*
* **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有兜底,`checklist` 没有 ——
* 配方(`auto-runner`)发审核卡时只递了 variant,于是那张卡穿着验收工单的衣服、`allowCustom` 也对,
* **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**,
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,而屏上还看不出来
* (清单区是空的,不是红的)。兜在这一处而不是让每个调用方自己记得:**模型路径将来漏传也一并保住**。
*
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审):`isReleaseStep` 那条口径决定了
* 一步可以只声明 `acceptanceChecklist`(给终局那张 `AcceptForm` 用)而不带 `cardVariant:'review'`
* (T_release 本身就是这种形状)。配方若在**这一步**发一张普通的 `as:'decide'` 卡(既没传
* `variant`,`step.cardVariant` 也不是 `review`),从前这里照样把 `acceptanceChecklist` 兜上去 ——
* 前端只在 `variant === 'review'` 时才渲染清单(`chat-bubbles.tsx`),人看不到勾可打,
* 而 `checkChatCardReply` 又要求 `checked ⊇ checklist`:卡按不下去。**只在 `reviewFace` 为真时**
* 才退回剧本声明的清单 —— 非审核面孔的卡,清单就是没有,不发明一条按不下去的。
*/
/**
* **执行工单的面孔** —— 两副变体:`data` 数据工单(件上的表就是要建的记录,确认即入库)/
* `accept` 验收工单(件只读,人只核对确认)。`review` 是决策卡那一路的面孔,不许串到这儿来;
* 认不出的值一律当没写(普通执行工单),不把脏东西端下去。
*
* **剧本声明(`PlanStep.cardVariant`)优先于卡骨架**:模型抄卡时会把 `variant` 抄丢
* (2026-08-27 实测:同一张卡 gateLedgers 抄到了、variant 没有)。但卡自己带的值同样作数 ——
* 配方那一路没有模型可抄丢,而**一个步骤里两笔条件互斥的 card**(缺东西派数据工单 /
* 都齐了派验收工单,2026-08-31)本来就不可能由一个 step 级的 `cardVariant` 说清。
*
* **这一处此前是内联的三目,写死只认 `'data'`**(2026-08-31 用户实测:「你怎么给我发成了
* 执行工单」)—— 配方递下来的 `accept` 在这儿被静默丢掉,对话区照旧摆「执行工单」。
* 配方那一头与前端那一头都有测,唯独**真发卡这条路**上没有:面孔要在这里断言。
*/
/**
* **这张放行卡打回得了吗**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有
* 重做的选项,因为这里都没有接入 ai」)。
*
* 打不回的判据只有一条:**这一步整个由配方跑完、一个字的 `guide` 都没有**(全 auto = 不问模型)。
* 那时打回的意见没有模型会去读,配方只会原样再提交一次,撞在「打回之后台账没动过就不许再提交」
* 那道闸上,连撞两拍之后落成一张「连续撞在同一条判据上」的兜底卡 —— 一颗按下去只会把单子
* 推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。有异议的人还有真路:别签,终止重开。
*
* 半 auto(`guide` 非空)与模型驱动的放行步照旧打得回 —— 那儿真有模型会读那句意见。
* 认不出那一步(没声明人工验收清单的老活动)也照旧 —— 不写这面旗就是老行为。
*/
export function releaseRedoable(step: { readonly auto?: unknown; readonly guide?: string } | undefined): boolean {
return !(step?.auto !== undefined && (step.guide ?? '').trim() === '');
}
export function executionCardVariant(fromCard: unknown, fromStep: unknown): 'data' | 'accept' | undefined {
for (const value of [fromStep, fromCard]) {
if (value === 'data' || value === 'accept') return value;
}
return undefined;
}
/**
* **确认闸的条件档从卡骨架到工单载荷**(2026-09-01 用户实测:「两个芯片类型不一样…
* 右边派发的卡片还可以点击下一步」)。配方声明了 `gateWhen`/`gateWhenSay`,发卡装配的
* 手写清单没点它们的名 —— 落库的工单载荷里两个字段是 undefined,按钮禁用与服务端拒收
* 读的都是它,一起无声失效(**手写字段清单第四次丢值**,且丢在 `variant` 上次被丢的同一段)。
* 这个缝取的是模型也会路过的不可信卡骨架,不能整包 spread —— 校验着转交:
* 条件必须是非空字符串;那句话只在有条件时才有意义(评估端只认 `when`)。
*/
export function cardConditionGate(card: Readonly<Record<string, unknown>>): { gateWhen?: string; gateWhenSay?: string } {
const when = card['gateWhen'];
if (typeof when !== 'string' || when.trim() === '') return {};
const say = card['gateWhenSay'];
return {
gateWhen: when,
...(typeof say === 'string' && say.trim() !== '' ? { gateWhenSay: say } : {}),
};
}
export function decisionCardFace(
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray<string> | undefined } | undefined,
): { reviewFace: boolean; checklist: ReadonlyArray<string> | undefined } {
const reviewFace = input.variant === 'review' || step?.cardVariant === 'review';
const checklist =
input.checklist !== undefined && input.checklist.length > 0
? input.checklist
: reviewFace
? step?.acceptanceChecklist
: undefined;
return { reviewFace, checklist };
}
/** 要不要去清单里查那一步:没给 itemId 查不了;面孔与清单都递全了不必查(省一次 manifest 取数) */
export function needsStepLookup(input: {
readonly itemId?: string | undefined;
readonly variant?: 'review' | undefined;
readonly checklist?: ReadonlyArray<string> | undefined;
}): boolean {
if (input.itemId === undefined) return false;
return input.variant === undefined || input.checklist === undefined;
}
export function rowReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{ domain: string; object: string }> = [],
@@ -425,6 +556,31 @@ export function touchedRows(
* 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假,
* 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。
*/
/**
* **决策卡的回答是留痕里的事实,不是模型的记账**(2026-08-27 W05 实测逼出):
* 审核那一步的入场哨兵读的是模型自写的 `review_approved_total`,人答了「重做」它照写 1,一路推到放行。
* 与 A6「该不该叫人不能由被叫的那一方说了算」同一课。基座按 card-reply 数:
* `decision_last` —— 这一单最后一张决策卡人选的值(原样;`custom:…` 保留前缀)
* `decision_<值>_total` —— 按值计数(`custom:` 开头的一律归 `decision_custom_total`)
* 值的语义(approve 是通过)归活动的哨兵去写;这里只把事实摆出来。
*/
export function decisionReadings(
trail: ReadonlyArray<{ readonly kind: string; readonly detail?: Record<string, unknown> | undefined }>,
): Record<string, number | string> {
const out: Record<string, number | string> = { decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 };
for (const entry of trail) {
if (entry.kind !== 'card-reply') continue;
const raw = entry.detail?.['value'];
if (typeof raw !== 'string' || raw.trim() === '') continue;
const value = raw.trim();
out['decision_last'] = value;
const token = value.startsWith('custom:') ? 'custom' : value.replace(/[^0-9a-zA-Z]+/g, '_');
const key = `decision_${token}_total`;
out[key] = Number(out[key] ?? 0) + 1;
}
return out;
}
export function toolCallReadings(
trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
declared: ReadonlyArray<{ readonly name: string }>,
@@ -902,12 +1058,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
*/
const two = (n: number): string => String(n).padStart(2, '0');
const opened = new Date(task.startedAt);
const openedAt = Number.isNaN(opened.getTime())
? ''
: `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` +
`${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`;
// 写法与运营中心沙箱看板圈行用的案号同源(`case-stamp.ts`):模型拿这串铸主键,看板拿同一串回查
const openedAt = openedAtText(task.startedAt);
const enriched: Record<string, unknown> = {
'case:openedAt': openedAt,
...scope,
@@ -917,6 +1069,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...tallyReadings(mine, declared),
...distinctReadings(mine, declared),
...toolCallReadings(trail, manifest.declarations.tools),
...decisionReadings(trail),
...mine,
...touched,
};
@@ -936,7 +1089,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
object: d.object,
label: d.label ?? `${d.domain}.${d.object}`,
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
/* 单标识列 —— 配方留痕点名行时按它削掉单号那一段(与 `rowLabel` 同一条口径) */
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
createOnly: d.createOnly === true,
}));
},
/**
@@ -959,7 +1115,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
patch: Record<string, unknown>;
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
dryRun?: boolean;
}) {
/** 值没变就不落笔(配方专用,见 `TaskDataGateway.draftWrite` 上的注释:落笔会擦掉「已落地」记号) */
skipUnchanged?: boolean;
/** 「只建不改」的表碰到已在库的键:跳过而不是拒(配方专用,同上) */
skipExisting?: boolean;
}): Promise<{ changed: boolean; skipped?: boolean }> {
const allowed = await taskData.writable(input.taskId);
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
if (target === undefined) {
@@ -975,6 +1135,35 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
}
/*
* **只建不改的表,键已在库就拒**(契约 `createOnly`,2026-08-27 W05 实测逼出):
* 数据工单让人从零建主数据,模型却可能给在库的料号也写一行空骨架 —— 件上多出一行
* 要人填的记录,红带点名它缺品牌,而正式表里它什么都不缺。剧本写了拦不住,写入那一刻查。
*/
if (target.createOnly && target.keys.length > 0) {
const formal = await deps.ledger.query({
address: { domain: input.domain, object: input.object },
where: {},
orderBy: [],
limit: 500,
offset: 0,
});
const landedKeys = (await deps.ledger.taskDrafts(input.taskId))
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
.map((d) => d.key);
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
/*
* **对模型是拒回,对配方是常态**(2026-08-29 终审 I2):那句话写的是
* 「别再写它,要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉」——
* 说给模型听的。配方没有「换个做法」这回事,抛出去就是一单永久停在那儿,
* 而人看见的是一句他既看不懂也办不了的拒回。它要的答案很简单:这一颗不用建。
*/
if (why !== undefined) {
if (input.skipExisting === true) return { changed: false, skipped: true };
throw new Error(why);
}
}
/*
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
@@ -1048,6 +1237,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
if (input.dryRun === true) return { changed };
/*
* **值没变就不落笔**(2026-08-29 终审 I2,配方专用旗)—— `DraftStore.put` 的 upsert
* 尾巴上有 `landed_at = null`:照写会把数据工单已确认入库的那些行**悄悄 un-land**。
* 模型那一路不传这个旗(它的空转写要被看见),行为一个字不变。
*/
if (input.skipUnchanged === true && !changed) return { changed };
await deps.ledger.draftWrite({
taskId: input.taskId,
address: { domain: input.domain, object: input.object },
@@ -1063,7 +1258,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* 回 `dropped: false` = 草稿层里根本没有这一行 —— 多半是主键写法对不上
* (`DAG…` 与 `DAG…-R0` 是两行);如实说比静默成功有用。
*/
async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record<string, string | number> }) {
async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record<string, string | number>; skipLanded?: boolean | undefined }) {
const allowed = await taskData.writable(input.taskId);
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
if (target === undefined) {
@@ -1073,6 +1268,30 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
})`,
);
}
/*
* **已入库的行撤不掉**(2026-08-27,数据工单确认即入库):那一行在数据工单确认那一刻就进了正式表,
* 草稿层里那一笔只是「本单写过它」的记号 —— 撤掉记号正式表一个字不动,反而让本单的读面少一行
* (`mine:` / `_rows` 全靠它;S1 的哨兵 `parts_profile_rows >= created` 会从此开不了)。
*/
const landedHit = (await deps.ledger.taskDrafts(input.taskId)).find(
(d) =>
d.landed &&
d.domain === input.domain &&
d.object === input.object &&
Object.keys(input.key).every((k) => String(d.key[k] ?? '') === String(input.key[k] ?? '')),
);
if (landedHit !== undefined) {
/*
* 配方 `prune` 传 `skipLanded`:已落地 = 已经是世界的一部分,不是陈旧草稿 ——
* 跳过是常态,不该整单炸掉(port.ts 上的大注释)。模型那一路不传,照旧硬拒:
* 那句拒回是它的行动指南。
*/
if (input.skipLanded === true) return { dropped: false, landed: true };
throw new Error(
`${target.label}里这一行(${Object.entries(input.key).map(([k, v]) => `${k}=${String(v)}`).join('、')})` +
'是本单在数据工单里建好、人确认后已入库的,撤不掉;要改由人在件上改,你接着办下一件事。',
);
}
const dropped = await deps.ledger.draftDrop({
taskId: input.taskId,
address: { domain: input.domain, object: input.object },
@@ -1180,6 +1399,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
};
const cards = {
/** 这一单正等人交回的卡 —— 工单没办结的 + 对话卡没作答、没被顶替的(见 human-tool `pendingCards`) */
async unanswered(taskId: TaskId) {
return (await deps.humanTool.pendingCards(taskId)).map((c) => ({ id: c.id, title: c.title }));
},
/**
* 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。
* 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
@@ -1289,6 +1512,9 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}>;
basis?: ReadonlyArray<{ k: string; v: string }>;
allowCustom?: boolean;
itemId?: string | undefined;
variant?: 'review' | undefined;
checklist?: ReadonlyArray<string> | undefined;
}) {
const cardSpan = deps.trace.beginAction({
taskId: input.taskId,
@@ -1297,10 +1523,31 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
input: { options: input.options.map((o) => o.value) },
});
const recommended = input.options.find((o) => o.recommended === true);
/*
* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。
* 面孔与**人工验收清单**都由调用方(引擎按当下那一步)定好递过来
* (`input.variant` / `input.checklist`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
* 没递才退回按 itemId 查剧本。
*
* **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有,`checklist` 没有 ——
* 配方发的审核卡(`auto-runner` 那一路只递了 variant)于是穿着验收工单的衣服、
* `allowCustom` 也对,**一条待确认都没有**:回传门 `checklistMissing` 在 checklist 缺席时恒过,
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,
* 而屏上还看不出来(清单区是空的,不是红的)。
* 兜在这一处而不是各调用方自己记得:**模型路径将来漏传也一并保住**。
*/
const declaredStep = needsStepLookup(input)
? (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
(st) => st.item === input.itemId,
)
: undefined;
const { reviewFace, checklist } = decisionCardFace(input, declaredStep);
const issued = await deps.humanTool.issueChatCard(
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
{
k: 'decision',
...(reviewFace ? { variant: 'review' as const } : {}),
...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }),
id: cardIdSchema.parse(ids.next()),
at: clock.now().toISOString(),
ask: input.ask,
@@ -1313,7 +1560,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...(o.consequence === undefined ? {} : { consequence: o.consequence }),
...(o.recommended === undefined ? {} : { recommended: o.recommended }),
})),
allowCustom: input.allowCustom ?? false,
/*
* 审核卡一律收自定义意见(2026-08-27 用户实测:填了重做意见点「重做」,卡上却写着
* 「你选了:驳回(不写具体意见)」)—— 模型常把骨架里的 allowCustom 抄丢,前端只好退回驳回项;
* 「重做」本来就是带意见退回去改,这一档由面孔定,不靠模型抄。
*/
allowCustom: input.allowCustom ?? reviewFace,
},
);
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
@@ -1326,6 +1578,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
/** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */
itemId?: string | undefined;
card: Record<string, unknown>;
}) {
const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap };
@@ -1602,10 +1856,31 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
);
const releaseBlocks = await fillBoundTables(input.taskId, editable);
/*
* 终局验收卡的人工验收清单:剧本里声明了 `acceptanceChecklist` 且不是审核卡那一步的(放行那一步)。
* 人核这份工单要走的步骤是固定的,由活动声明、这里带上卡,不靠模型抄。
*/
const releaseStepDecl = manifest.assets.businessModel.steps?.find(
(st) => st.acceptanceChecklist !== undefined && st.acceptanceChecklist.length > 0 && st.cardVariant !== 'review',
);
const releaseChecklist = releaseStepDecl?.acceptanceChecklist;
/*
* **打回之后没人接得住的,就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的
* 验收卡不应该有重做的选项,因为这里都没有接入 ai」)。
*
* 判据是这一步**整个由配方跑完、一个字的 `guide` 都没有**(全 auto = 不问模型):
* 那时打回的意见没有模型会去读,配方只会原样再提交一次,撞在「打回之后台账没动过就不许
* 再提交」那道闸上,连撞两拍之后落成一张「连续撞在同一条判据上」的兜底卡 —— 一颗按下去
* 只会把单子推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。
* 半 auto(`guide` 非空)与模型驱动的放行步照旧摆着 —— 那儿真有模型会读那句意见。
*/
const redoable = releaseRedoable(releaseStepDecl);
const submitted = await deps.release.submitForRelease({
taskId: input.taskId,
ownerId: input.ownerId,
humanCallCap: input.humanCallCap,
...(releaseChecklist === undefined ? {} : { checklist: releaseChecklist }),
...(redoable ? {} : { redoable: false }),
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
@@ -1694,6 +1969,19 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const gateLedgers = Array.isArray(input.card['gateLedgers'])
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
: [];
/*
* 数据工单 = 执行工单的变体(契约 `variant`)—— **由剧本声明、这里决定**(`PlanStep.cardVariant`),
* 不靠模型抄卡骨架:说明书里写着 `"variant":"data"`,模型抄卡时把它丢了(2026-08-27 实测,
* 同一张卡 gateLedgers 抄到了、variant 没有),对话区于是照旧摆「执行工单」。
* 与 `view` 同一条道理:这一步派的是什么单,写剧本时就定死,模型写不写都一样。卡上写了只当提示。
*/
const stepVariant =
input.itemId === undefined
? undefined
: (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
(s) => s.item === input.itemId,
)?.cardVariant;
const variant = executionCardVariant(input.card['variant'], stepVariant);
const issued = await deps.humanTool.issueExecution(context, {
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
@@ -1703,6 +1991,9 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...(standard === '' ? {} : { standard }),
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
...(variant === undefined ? {} : { variant }),
/* 条件档(gateWhen/gateWhenSay)—— 校验着转交,见 cardConditionGate 的账 */
...cardConditionGate(input.card),
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
@@ -2146,6 +2437,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
tasks: taskContext,
clock,
/*
* **裸网关不行**(2026-08-29 终审 C1):从前这里传的是 `deps.modelGateway` ——
* 配方里 `analyze` 烧的 token 一个数都不计入活动预算(计量全靠调用方给的 `onUsage`),
* 时间轴上也不产生模型 span。agent 那两条路(:2194 / :2242)一直是 `tracedGateway` + `onAgentUsage`,
* 只有这一条漏了 —— 一个能问模型却不花钱的口子,预算这道闸对它就是不存在的。
*/
analyze: createAnalyzeGateway(tracedGateway, onAgentUsage),
});
/**
+2
View File
@@ -18,6 +18,8 @@ const taskConsumer: Consumer = async (delivery, container) => {
kind,
ref: envelope.correlation.cause.ref,
payload: envelope.payload.data,
// 发生时刻要带过去:人事件靠它判「这条回传是不是已经被上一拍处理过了」
occurredAt: envelope.occurredAt,
});
};
@@ -41,7 +41,11 @@
- **进化必过治理**:候选 → 回放 → 人放行,状态机不许跳过回放
- **安装恒为新增、版本并存**:卸载只置 state,在途任务按其启动时版本跑完
近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities` 出参加 `contract` 面
近变(2026-08-27):模块面未变。app 层那两条路由改名 `GET/POST /api/gov/activity-packages`(从前叫 `/api/gov/activities`),
`/api/gov/activities/*` 整个让给运营中心的活动模块(L1/L2/L3,`packages/contracts/src/api/activity-ops.ts`);
运营端的禁用 / 激活走本模块既有的 `setEnabled`(从前只有 seed 调它,没有 HTTP 口)。
近变(2026-07-28):模块面未变。app 层的 `GET /api/gov/activities`(现 `/api/gov/activity-packages`)出参加 `contract` 面
(必填入参 / 入参字段 / 输出口与载荷字段 / 声明的工具·模型·域对象·岗位 / 调人预算),
由 `manifestOf` 逐个读清单组装。此前治理台看不到活动的契约:
编排的人引用一个活动却不知道它吃什么,边映射只能靠猜;
@@ -348,3 +348,477 @@ describe('refersTo 目标要在本活动的声明里 —— 写错的地址会
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
});
});
/**
* ── 配方静态审(2026-08-29)──────────────────────────────────────────
*
* `autoRecipeFindings` 读的是**整份** `ActivityManifest`(不像本文件其余审读只收窄到
* 字段级),所以这里不能再用局部的 `manifest()` / `table()` 这类窄类型 helper ——
* 借 `activityManifestSchema.parse()` 把六区的默认值一次性填满,只手写真正必填的那几处。
*/
import { activityManifestSchema } from '@contracts';
import { autoRecipeFindings } from '../internal/domain/static-audit.ts';
/** 台账地址与列名全部真实声明过 —— `demo.record` 只有 `case_ref` / `note` 两列 */
function manifestFixture(): ActivityManifest {
return activityManifestSchema.parse({
identity: {
activityId: 'demo-activity',
name: '演示活动',
packageVersion: '1.0.0',
category: '演示',
sdkVersion: '1.0.0',
checksum: 'chk',
},
contract: {
activityId: 'demo-activity',
version: '1.0.0',
input: { data: { type: 'object', properties: {} } },
output: {},
authorization: { humanCallBudget: 0 },
resources: { budget: { steps: 10, tokens: 10000, activeMinutes: 10, humanCalls: 0 } },
},
declarations: {
models: ['m'],
ledger: [
{
domain: 'demo',
object: 'record',
kind: 'record',
caseKey: 'case_ref',
label: '演示记录',
fields: [
{ name: 'case_ref', type: 'text', primaryKey: true },
{ name: 'note', type: 'text' },
],
},
],
harness: {},
},
assets: {
businessModel: {
cmmnXml: '<caseFileItem id="S1" name="x"/>',
narrative: '演示说明书',
dmn: { drd: [], tables: [] },
steps: [],
},
},
samples: [{ name: '样例', input: {}, expect: { ports: ['done'] } }],
entry: { file: 'index.js', checksum: 'chk' },
});
}
describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () => {
const withRecipe = (auto: unknown) => {
const base = manifestFixture();
return {
...base,
assets: {
...base.assets,
businessModel: {
...base.assets.businessModel,
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
},
},
} as never;
};
it('台账地址不在 declarations.ledger 里 → error', () => {
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }));
expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
});
it('列名不在该对象的声明里 → error(列名写错 = 那一格永远是空的)', () => {
const findings = autoRecipeFindings(
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true);
});
it('each 的地址同样要在声明里', () => {
const findings = autoRecipeFindings(
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'mine:nope.nope' }, fields: {} }] }),
);
expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true);
});
/*
* 2026-08-29 评审(Minor):上面那条「each / lookup 的地址同样要在声明里」的名字曾经名不副实 ——
* 用例体只测了 `from.each`,从没有一条真的在 `project.fields` 里放过 `{ lookup, by, take }` 节点。
* `walkExpr` 的 lookup 递归分支因此只被间接触到,card/say 那个洞就是这么躲过测试套件的(见下方)。
*/
it('project.fields 里的 lookup 地址同样要在声明里', () => {
const findings = autoRecipeFindings(
withRecipe({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: { note: { lookup: 'nope.nope', by: {}, take: 'x' } },
},
],
}),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
});
/*
* 2026-08-29 评审(Important):`card.title` / `card.ask` / `card.standard` / `card.basis[].v`
* 全是 AutoExpr,合法地可以含 lookup —— 从前只核了 `gateLedgers`,这四处没被 `walkExpr` 走过,
* 卡面上的一处笔误因此能一声不响地装进来。`title` 这里刻意嵌一层 `when/then` ——
* 顺手核实 `walkExpr` 对嵌套结构是不是真的递归到底(不是只查表层)。
*/
it('card 的 title / ask / standard / basis 里藏的 lookup 地址也要核(含嵌套在 when/then 里的)', () => {
const findings = autoRecipeFindings(
withRecipe({
do: [
{
kind: 'card',
as: 'execution',
title: { when: 'a = 1', then: { lookup: 'title.nope', by: {}, take: 'x' }, else: 'x' },
ask: { lookup: 'ask.nope', by: {}, take: 'x' },
standard: { lookup: 'standard.nope', by: {}, take: 'x' },
basis: [{ k: 'b', v: { lookup: 'basis.nope', by: {}, take: 'x' } }],
},
],
}),
);
const messages = findings.map((f) => f.message).join('\n');
expect(messages).toContain('title.nope');
expect(messages).toContain('ask.nope');
expect(messages).toContain('standard.nope');
expect(messages).toContain('basis.nope');
});
/* 2026-08-29 评审(Important):say.text 同样是 AutoExpr —— 从前压根没有 say 分支 */
it('say.text 里藏的 lookup 地址也要核', () => {
const findings = autoRecipeFindings(
withRecipe({ do: [{ kind: 'say', text: { lookup: 'say.nope', by: {}, take: 'x' } }] }),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('say.nope'))).toBe(true);
});
it('in: 与 result: 不是台账地址,不该被当成台账来审', () => {
/* 主键列照给 —— 少了它这份配方本来就是坏的(2026-08-29 新增的那条审读),与本条要验的事无关 */
const findings = autoRecipeFindings(
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'in:pns' }, fields: { case_ref: { col: 'pn' } } }] }),
);
expect(findings).toEqual([]);
});
it('analyze 的出参没人消费 → warning(白烧一次模型调用)', () => {
const findings = autoRecipeFindings(
withRecipe({ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { once: true }, fields: {} }, output: { type: 'object' } }] }),
);
expect(findings.some((f) => f.severity === 'warn' && f.message.includes('items'))).toBe(true);
});
it('submit 那一笔所在的步没有人工验收清单 → error(系统提交不比模型提交宽松)', () => {
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] }));
expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true);
});
/*
* **card 后面还有笔 → warn**(2026-08-29 终审 I3 附带):卡真发出去那一拍到此为止,
* 排在后面的笔要等人交回之后的那一拍才跑。写配方的人常按顺序读成「铺格 → 发卡 → 收口整理」
* 一拍连做 —— 那是读错的,装的时候提前说一声。
*/
it('card 后面还有笔 → warn(它们要等人交回之后的那一拍才跑)', () => {
const findings = autoRecipeFindings(
withRecipe({
do: [
{ kind: 'card', as: 'execution', title: 'x' },
{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'a' } },
],
}),
);
expect(findings.some((f) => f.severity === 'warn' && f.message.includes('card 后面还有 1 笔'))).toBe(true);
});
it('card 是最后一笔:不出这条 warn', () => {
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] }));
expect(findings.some((f) => f.message.includes('card 后面还有'))).toBe(false);
});
/* ── 2026-08-29 终审 I4:spec §5.7 明列却一条都没审的几处 ────────────────── */
it('**动作级 when 写不成 S-FEEL → error**(运行时它会抛,整步交人)', () => {
const findings = autoRecipeFindings(
withRecipe({ do: [{ kind: 'say', when: 'note >>= (', text: 'x' }] }),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true);
});
it('**嵌在表达式里的 when 也审语法** —— 它出错是静默走 else,更难查', () => {
const findings = autoRecipeFindings(
withRecipe({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: { case_ref: 'a', note: { when: 'note = = 1', then: 'x', else: 'y' } },
},
],
}),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true);
});
it('动作级 when 引用了台账上没有的名字 → error(S-FEEL 求值为假,这一笔永远被跳过)', () => {
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'candidatesConfirmed > 0', text: 'x' }] }));
expect(findings.some((f) => f.severity === 'error' && f.message.includes('candidatesConfirmed'))).toBe(true);
});
it('内建函数名不算未绑定的台账名 —— 名字取 AST 的 references,不是正则扫标识符', () => {
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'count(note) > 0', text: 'x' }] }));
expect(findings).toEqual([]);
});
it('动作级 when 引用台账列 / 基座读数 / 配方 let 都算绑得上', () => {
const findings = autoRecipeFindings(
withRecipe({
let: { evalRef: 'W05-1' },
do: [{ kind: 'say', when: 'demo_record_rows > 0 and note != "" and evalRef != ""', text: 'x' }],
}),
);
expect(findings).toEqual([]);
});
it('**嵌套的 when 不审名字** —— 它看得见行内的列,拿 boundNames 去卡会误报', () => {
const findings = autoRecipeFindings(
withRecipe({
do: [
{
kind: 'project',
into: 'demo.record',
from: { each: 'demo.record' },
fields: { case_ref: { col: 'case_ref' }, note: { when: '本行的列 = "x"', then: 'a', else: 'b' } },
},
],
}),
);
expect(findings).toEqual([]);
});
it('submit.rationale 里藏的 lookup 地址也要核', () => {
const base = manifestFixture();
const manifest = {
...base,
assets: {
...base.assets,
businessModel: {
...base.assets.businessModel,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
acceptanceChecklist: ['核一遍'],
auto: { do: [{ kind: 'submit', rationale: { lookup: 'rationale.nope', by: {}, take: 'x' } }] },
},
],
},
},
} as never;
expect(autoRecipeFindings(manifest).some((f) => f.message.includes('rationale.nope'))).toBe(true);
});
it('recipe.let 里藏的 lookup 地址也要核(那一圈从前根本没被 walkExpr 走过)', () => {
const findings = autoRecipeFindings(
withRecipe({ let: { ref: { lookup: 'let.nope', by: {}, take: 'x' } }, do: [{ kind: 'say', text: 'x' }] }),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('let.nope'))).toBe(true);
});
it('project.fields 少了目标表的主键列 → error(运行时那一行会被静默跳过)', () => {
const findings = autoRecipeFindings(
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { note: 'x' } }] }),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没给主键列:case_ref'))).toBe(true);
});
it('prune 的表又列在 humanWrites 里 → warn(人加的行会被下一拍静默撤掉)', () => {
const base = manifestFixture();
const manifest = {
...base,
assets: {
...base.assets,
businessModel: {
...base.assets.businessModel,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
humanWrites: ['demo.record'],
auto: { do: [{ kind: 'project', into: 'demo.record', prune: true, from: { once: true }, fields: { case_ref: 'a' } }] },
},
],
},
},
} as never;
expect(autoRecipeFindings(manifest).some((f) => f.severity === 'warn' && f.message.includes('humanWrites'))).toBe(true);
});
it('card 没有 basis → error(基座拒发 basis 为空的工单,跑到那儿就停了)', () => {
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] }));
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没有 basis'))).toBe(true);
});
/*
* ── 字段求值依赖成环(2026-08-30)──────────────────────────────────────
*
* `fields` 存进 jsonb 之后键序不再保证:`judgement_class` 依赖同笔内的
* `difference_status`,一旦被排到它前面,`when` 链求值恒读到 `undefined`,
* 三级判断全部落空却不报错(见 `judgement-class-probe.md`)。运行时已经改成
* 按依赖排序求值,但 A 依赖 B、B 又依赖 A 排不出任何安全序 —— 那是配方写错了,
* 装的时候就该拒。`demo.record` 默认只有 case_ref/note 两列,这里额外声明 a/b
* 两列,专给这组用例摆弄依赖关系(不然「列名不在声明里」那条审读会先报,污染断言)。
*/
const withExtraColumns = (auto: unknown) => {
const base = manifestFixture();
const [record] = base.declarations.ledger;
return {
...base,
declarations: {
...base.declarations,
ledger: [
{
...record,
fields: [...(record?.fields ?? []), { name: 'a', type: 'text', primaryKey: false }, { name: 'b', type: 'text', primaryKey: false }],
},
],
},
assets: {
...base.assets,
businessModel: {
...base.assets.businessModel,
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
},
},
} as never;
};
it('project.fields 两个字段互相依赖(a 的 when 提到 b、b 的 when 提到 a)→ error,点名两个字段', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: { case_ref: 'x', a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
},
],
}),
);
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
expect(hit).toBeDefined();
expect(hit?.message).toContain('a');
expect(hit?.message).toContain('b');
});
it('project.fields 链式依赖(不是环,只是 b 引用 a)→ 不报循环依赖', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: { case_ref: 'x', a: 'seed', b: { when: 'a = "seed"', then: '1', else: '0' } },
},
],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
it('analyze 入参字段成环 → error(与 project.fields 同一条审读)', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'analyze',
model: 'm',
into: 'items',
instruction: 'x',
input: {
from: { once: true },
fields: { a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
},
output: { type: 'object' },
},
],
}),
);
expect(findings.some((f) => f.severity === 'error' && f.message.includes('循环依赖'))).toBe(true);
});
it('三级依赖链(与真实 W05 的 difference_status → judgement_class → risk_level 同形)→ 不报循环依赖', () => {
const findings = autoRecipeFindings(
withExtraColumns({
do: [
{
kind: 'project',
into: 'demo.record',
from: { once: true },
fields: {
case_ref: 'x',
note: { when: 'a = "hit"', then: '1', else: '0' },
a: { when: 'b = "hit"', then: 'hit', else: 'miss' },
b: 'hit',
},
},
],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
/*
* **`recipe.let` 同一条审读**(2026-08-30 全分支终审 Important 1)——`let` 与 `fields`
* 的依赖读法不一样(`let` 的官方跨条目读法是 `{in:X}`,不是 `when` 里的裸标识符),
* 所以静态审那边是单独一份 `letsCycle`/`letReferences`,这里单独一组测试钉住它,
* 不是「已经被 fields 那组测过了」。
*/
it('recipe.let 两条互相依赖({in:X} 互指)→ error,点名两个 let', () => {
const findings = autoRecipeFindings(
withRecipe({
let: { a: { in: 'b' }, b: { in: 'a' } },
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
}),
);
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
expect(hit).toBeDefined();
expect(hit?.message).toContain('a');
expect(hit?.message).toContain('b');
expect(hit?.message).toContain('let');
});
it('recipe.let 链式依赖(不是环,只是 tag 用 {in:"stamp"} 读 stamp)→ 不报循环依赖', () => {
const findings = autoRecipeFindings(
withRecipe({
let: { stamp: 'W05-', tag: { cat: [{ in: 'stamp' }, 'X'] } },
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: { in: 'tag' } } }],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
it('recipe.let 引用起单入参 / case: 这类全局名字不算依赖,不误报循环依赖', () => {
const findings = autoRecipeFindings(
withRecipe({
let: { a: { in: 'someInput' }, b: { in: 'case:openedAt' } },
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
});
@@ -7,7 +7,10 @@
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
* 本文件只做**纯**判定。
*/
import type { ActivityManifest, StaticAuditFinding } from '@contracts';
import type { ActivityManifest, AutoExpr, StaticAuditFinding } from '@contracts';
/* domain 只准 @contracts / @sfeel / @utils(编码规范 §5.3)—— S-FEEL 是纯函数包,零 IO */
import { staticCheck } from '@sfeel';
import { topoOrder } from '@utils';
export type ExternalChecks = {
/** 声明引用但工具箱里没有的工具 */
@@ -311,6 +314,14 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
for (const tool of manifest.declarations.tools ?? []) {
out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`);
}
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
/*
* **人把这一步打回重做时写下的那句话**(2026-09-01)—— 引擎每一拍都注给配方(没打回就是空串,
* 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。活动据它决定「这一轮要不要把意见交回给 AI 重判」。
* 名字用下划线而不是 `case:` 前缀:S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 `when` 条件用。
*/
out.add('case_redo_note');
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]);
@@ -374,6 +385,403 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
return out;
}
/**
* 递归找出表达式里全部 `when` 字符串引用的根路径名(用于判「这个字段依赖同笔内的哪几个字段」)。
*
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `checkWhen` 同一个理由:
* 正则会把 `count(...)` 这类内建函数名也当成字段名。`when` 语法本该被 `checkWhen` 先一步
* 拦住,这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
*
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `whenReferences` 是同一个算法,
* 写了两遍**:装包静态审(L4 `activity-registry`)不能 import 配方执行器(L5
* `loop-engine`,模块分层只准低层被高层引用,§4),这一段小小的、纯粹依附 `AutoExpr`
* 形状的逻辑只能各自留一份;真正通用、不含业务语义的图算法(`topoOrder`)已经
* 提到 `@utils` 共用了。改动这个函数时,记得看看那边要不要一起改。
*/
function fieldReferences(expr: AutoExpr): readonly string[] {
if (typeof expr !== 'object' || expr === null) return [];
if ('when' in expr) {
const verdict = staticCheck(expr.when);
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
return [...names, ...fieldReferences(expr.then), ...fieldReferences(expr.else)];
}
if ('lookup' in expr) {
const byNames = Object.values(expr.by).flatMap((value) => fieldReferences(value));
return expr.else === undefined ? byNames : [...byNames, ...fieldReferences(expr.else)];
}
if ('cat' in expr) return expr.cat.flatMap((part) => fieldReferences(part));
if ('fmt' in expr) return fieldReferences(expr.fmt);
return [];
}
/**
* `fields` 内部字段求值依赖能不能排出安全序(与运行时 `orderFields` 同一件事,见上面
* `fieldReferences` 的头注)。排得出来给 `undefined`;排不出来(真正的环)把牵涉的字段名
* 摆出来 —— **这条错误 2026-08-30 才补上**:jsonb 会把 `fields` 对象的键序打乱,
* `judgement_class`/`risk_level` 这类依赖同笔内另一个字段的写法一旦被排到依赖对象前面,
* 求值时读到的是 `undefined`,`when` 链全部落空却不报错(详见
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`);
* 运行时已经改成按依赖排序求值,但**真正的环**(A 依赖 B、B 又依赖 A,排不出任何安全序)
* 是配方写错了 —— 别留到运行时才炸,装包这一刻就能看出来。
*/
function fieldsCycle(fields: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
const entries = Object.entries(fields);
const names = new Set(entries.map(([name]) => name));
const deps = new Map<string, readonly string[]>(
entries.map(([name, expr]) => [name, [...new Set(fieldReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
);
const result = topoOrder(
entries.map(([name]) => name),
(name) => deps.get(name) ?? [],
);
return result.ok ? undefined : result.cycle;
}
/**
* 同上,但专为 `recipe.let` 用(2026-08-30 全分支终审 Important 1)——多认一种依赖:
* **裸 `{in:X}` 本身就是依赖**。`let` 的官方跨条目读法就是 `{in:X}`(见
* `packages/contracts/src/auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能
* {in:'名'} 引用」,运行时 `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——
* 与 `fields` 相反(`fieldReferences` 故意不认 `{in:X}`:那边的 `{in:X}` 只读
* `lets`/`scope`,永远不会命中同一个 `fields` 对象里的另一个字段,认了反而误报)。
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `fieldReferences` 完全一致。
*
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `letReferences` 是同一个算法,
* 写了两遍**——理由同 `fieldReferences` 头注(L4 装包静态审不能 import L5 配方执行器)。
* 改动这个函数时,记得看看那边要不要一起改。
*/
function letReferences(expr: AutoExpr): readonly string[] {
if (typeof expr !== 'object' || expr === null) return [];
if ('in' in expr) return [expr.in];
if ('when' in expr) {
const verdict = staticCheck(expr.when);
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
}
if ('lookup' in expr) {
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
}
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
if ('fmt' in expr) return letReferences(expr.fmt);
return [];
}
/** `recipe.let` 内部求值依赖能不能排出安全序(与运行时 `orderLets` 同一件事)。装的时候就该看出来,不留到运行时才炸。 */
function letsCycle(lets: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
const entries = Object.entries(lets);
const names = new Set(entries.map(([name]) => name));
const deps = new Map<string, readonly string[]>(
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
);
const result = topoOrder(
entries.map(([name]) => name),
(name) => deps.get(name) ?? [],
);
return result.ok ? undefined : result.cycle;
}
/**
* **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。
*
* 代码错是必然的错,模型错是概率的错。必然的错好查 —— 前提是它在装包那一刻就被拦下,
* 而不是等到第一单跑起来才现形。
*/
export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
const ledgers = new Map(
manifest.declarations.ledger.map((l) => [
`${l.domain}.${l.object}`,
{
columns: new Set(l.fields.map((f) => f.name)),
/* 主键列 —— 与运行时 `writable()` 同一口径(`fields.filter(primaryKey)`) */
keys: l.fields.filter((f) => f.primaryKey).map((f) => f.name),
},
]),
);
/**
* 动作级 `when` 求值时的作用域 = 任务视图 + 基座读数 + 起单入参(`boundNames`)+ 配方的 `let`。
* **不含**行内的列(`col` / `outer` / `computed`)—— 那些只有嵌在表达式里的 `when` 看得见,
* 所以名字绑定只审动作级那一层,嵌套的那些只审语法(见下面 `checkWhen`)。
*/
const declaredNames = boundNames(manifest);
const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name));
/** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */
const checkAddress = (raw: string, at: string): void => {
if (raw.startsWith('in:') || raw.startsWith('result:')) return;
const address = raw.startsWith('mine:') ? raw.slice('mine:'.length) : raw;
if (!ledgers.has(address)) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `配方引的台账地址不在声明里:${address}(写的授权面是台账声明)`,
at,
});
}
};
/**
* **`when` 的 S-FEEL 要能解析**(spec §5.7 第三条,2026-08-29 终审 I4 补 —— 从前一条都没审)。
* 运行时两种下场都不好:嵌在表达式里的 `{when}` 出错 → `evalExpr` catch 掉、**静默走 else**;
* 动作级的 `when` 出错 → 抛 → 整步交人。写错一个字的代价不该是一单跑不动。
*
* 给了 `lets` 就连**名字绑得上**一起审(只有动作级那一层能这么审:嵌套的 `when`
* 还看得见行内的 `col` / `outer` / `computed`,拿 `boundNames` 去卡会误报)。
*/
const checkWhen = (expr: string, at: string, lets?: ReadonlySet<string>): void => {
const verdict = staticCheck(expr);
if (!verdict.ok) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `配方的 when 过不了 S-FEEL 静态检(写不成 S-FEEL):${expr} —— ${verdict.issues.map((i) => i.message).join(';')}`,
at,
});
return;
}
if (lets === undefined) return;
/*
* 名字取 `staticCheck` 的 `references`(AST 里的路径节点),不用本文件那个正则 `predicateNames` ——
* 正则会把 `count(...)` 这类**内建函数名**也当成台账名报未绑定;AST 分得清函数与路径。
*/
for (const path of verdict.references) {
const name = path.split('.')[0] ?? path;
if (declaredNames.has(name) || lets.has(name)) continue;
out.push({
code: 'schema-invalid',
severity: 'error',
message:
`配方的 when 引用了 \`${name}\`,而台账、案卷、起单入参与本配方的 let 里都没有这个名字 —— ` +
'S-FEEL 对未绑定的名字求值为假,这一笔将**永远被跳过**(而且一声不响)',
at,
});
}
};
/** 表达式里也可能藏着 lookup 地址与 when,递归找一遍 */
const walkExpr = (expr: unknown, at: string): void => {
if (typeof expr !== 'object' || expr === null) return;
const node = expr as Record<string, unknown>;
if (typeof node['when'] === 'string') checkWhen(node['when'], at);
if (typeof node['lookup'] === 'string') {
checkAddress(node['lookup'], at);
for (const value of Object.values((node['by'] ?? {}) as Record<string, unknown>)) walkExpr(value, at);
if (node['else'] !== undefined) walkExpr(node['else'], at);
}
if (Array.isArray(node['cat'])) for (const part of node['cat'] as unknown[]) walkExpr(part, at);
if (node['fmt'] !== undefined) walkExpr(node['fmt'], at);
if (node['then'] !== undefined) walkExpr(node['then'], at);
if (typeof node['when'] === 'string' && node['else'] !== undefined) walkExpr(node['else'], at);
};
const walkSource = (source: unknown, at: string): void => {
if (typeof source !== 'object' || source === null) return;
const node = source as Record<string, unknown>;
if (typeof node['each'] === 'string') checkAddress(node['each'], at);
for (const value of Object.values((node['where'] ?? {}) as Record<string, unknown>)) walkExpr(value, at);
if (typeof node['then'] === 'object' && node['then'] !== null) walkSource(node['then'], at);
if (Array.isArray(node['rows'])) {
for (const row of node['rows'] as unknown[]) {
for (const value of Object.values(row as Record<string, unknown>)) walkExpr(value, at);
}
}
};
for (const step of manifest.assets.businessModel.steps ?? []) {
const recipe = step.auto;
if (recipe === undefined) continue;
const at = `assets.businessModel.steps[${step.item}].auto`;
const consumed = new Set<string>();
/* 配方级 `let`:名字进动作级 `when` 的作用域;**它自己的表达式也是 AutoExpr,一样要走一遍** */
const letNames = new Set(Object.keys(recipe.let ?? {}));
for (const [name, expr] of Object.entries(recipe.let ?? {})) walkExpr(expr, `${at}.let.${name}`);
/*
* **`let` 之间的求值依赖不能成环**(2026-08-30 全分支终审 Important 1,与下面 `fields`
* 那条 `fieldsCycle` 同一课):`let` 存进 jsonb 之后键序不再保证,运行时已经按依赖
* (`{in:X}`)排出安全序求值(见 `orderLets` 头注),但 A 依赖 B、B 又依赖 A 排不出
* 任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
*/
{
const cycle = letsCycle(recipe.let ?? {});
if (cycle !== undefined) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `let 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个 let 是否互相用 {in:...} 或 when 引用了对方)`,
at: `${at}.let`,
});
}
}
for (const [index, action] of recipe.do.entries()) {
const where = `${at}.do[${index}]`;
/* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */
if (action.when !== undefined) checkWhen(action.when, where, letNames);
/* 确认闸的条件档与动作级 when 同一作用域(基座读数),同一套审 */
if (action.kind === 'card' && action.gateWhen !== undefined) checkWhen(action.gateWhen, where, letNames);
if (action.kind === 'project') {
checkAddress(action.into, where);
walkSource(action.from, where);
const target = ledgers.get(action.into);
for (const [name, expr] of Object.entries(action.fields)) {
walkExpr(expr, where);
if (target !== undefined && !target.columns.has(name)) {
out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where });
}
}
/*
* **prune 的表不该是人在件上能加行的表**(2026-08-29 终审 F3)。
* `prune` 说的是「这一笔投影出来的行就是全部,多出来的撤掉」;而 `humanWrites`
* 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有,
* 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。
*/
if ((action.prune === true || action.prune === 'live') && (step.humanWrites ?? []).includes(action.into)) {
out.push({
code: 'schema-invalid',
severity: 'warn',
message:
`${action.into} 既被这一笔 prune 投影、又列在这一步的 humanWrites 里 —— ` +
'人在件上加的行配方的数据源里没有,下一拍对齐会把它撤掉(静默);' +
'要么别 prune 这张表,要么它就不该是人能加行的表',
at: where,
});
}
/*
* **主键列一个都不能少**(2026-08-29 终审 I4)。运行时 `auto-runner` 拿
* `decl.keys` 逐列取值,取不到就当空主键 —— 那一行**静默 `continue`**:
* 一行不写、一条痕都没有。表里少了行,人和判据都查不出为什么。
* 装包时判得出来的事,不留到运行时。
*/
if (target !== undefined) {
const missing = target.keys.filter((k) => !(k in action.fields));
if (missing.length > 0) {
out.push({
code: 'schema-invalid',
severity: 'error',
message:
`配方往 ${action.into} 投影,却没给主键列:${missing.join('、')}(主键:${target.keys.join('、')})—— ` +
'运行时主键算不出来的行会被静默跳过,一行不写、一条痕都没有',
at: where,
});
}
}
/*
* **字段求值依赖不能成环**(2026-08-30)。`fields` 存进 jsonb 之后键序不再保证,
* 运行时已经按依赖排出安全序求值(见上面 `fieldsCycle` 的头注),但 A 依赖 B、
* B 又依赖 A 排不出任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
*/
{
const cycle = fieldsCycle(action.fields);
if (cycle !== undefined) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `${action.into} 的 fields 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
at: where,
});
}
}
}
if (action.kind === 'analyze') {
walkSource(action.input.from, where);
for (const expr of Object.values(action.input.fields)) walkExpr(expr, where);
if (!manifest.declarations.models.includes(action.model)) {
out.push({ code: 'schema-invalid', severity: 'error', message: `配方用的模型没在 declarations.models 里声明:${action.model}`, at: where });
}
{
const cycle = fieldsCycle(action.input.fields);
if (cycle !== undefined) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `analyze 入参字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
at: where,
});
}
}
}
if (action.kind === 'card') {
for (const address of action.gateLedgers ?? []) checkAddress(address, where);
if (action.as === 'decide' && (action.options ?? []).length === 0) {
out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where });
}
/*
* **basis 空着的卡基座直接拒发**(契约 `auto-recipe.ts` 的注释自己写着「别指望兜底」)——
* 运行时那一拒是抛,抛出去就是整步交人。装包时看得见的事不留到运行时。
*/
if ((action.basis ?? []).length === 0) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: '配方发的卡没有 basis(调用必附上下文)—— 基座拒发 basis 为空的工单,这一步跑到这儿就停了',
at: where,
});
}
/*
* **`title`/`ask`/`standard`/`basis[].v` 都是 AutoExpr,合法地可以含 lookup**
* (2026-08-29 评审补,原实现只核了 `gateLedgers`,这四处从没被 `walkExpr` 走过 ——
* 卡面上的一处笔误因此能一声不响地装进来,只在这一步第一次真跑起来时才现形)。
*/
walkExpr(action.title, where);
if (action.ask !== undefined) walkExpr(action.ask, where);
if (action.standard !== undefined) walkExpr(action.standard, where);
for (const b of action.basis ?? []) walkExpr(b.v, where);
/*
* **card 后面还有笔:说清它们什么时候跑**(2026-08-29 终审 I3 附带的一条 warn)。
* 卡真发出去那一拍到此为止(`stop`);排在后面的笔要等**人交回之后**的那一拍才跑
* (那一拍这一笔 `card` 因为 `carded` 被跳过)。写配方的人常按顺序读成
* 「铺格 → 发卡 → 收口整理」一拍连做 —— 那是读错的,提前说一声。
*/
if (index < recipe.do.length - 1) {
out.push({
code: 'schema-invalid',
severity: 'warn',
message:
`card 后面还有 ${recipe.do.length - index - 1} 笔:卡发出去那一拍到此为止,` +
'这几笔要等人交回之后的那一拍才跑(那一拍 card 这一笔被跳过)—— 确认这就是你要的次序',
at: where,
});
}
}
/* `say.text` 同样是 AutoExpr,同一个理由要核(2026-08-29 评审补) */
if (action.kind === 'say') {
walkExpr(action.text, where);
}
/* `submit.rationale` 同样是 AutoExpr,合法地可以含 lookup —— 从前这一支只查 acceptanceChecklist */
if (action.kind === 'submit') walkExpr(action.rationale, where);
if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: 'submit 那一步必须声明人工验收清单(acceptanceChecklist)—— 系统提交不比模型提交宽松',
at: where,
});
}
/* 找出 `result:xxx` 被谁消费了 —— analyze 的出参没人用,就是白烧一次模型调用 */
const text = JSON.stringify(action);
for (const hit of text.matchAll(/"result:([A-Za-z0-9_]+)"/g)) consumed.add(hit[1] ?? '');
}
for (const action of recipe.do) {
if (action.kind !== 'analyze') continue;
if (!consumed.has(action.into)) {
out.push({
code: 'schema-invalid',
severity: 'warn',
message: `analyze 的出参 result:${action.into} 没有任何一笔消费它 —— 白烧一次模型调用`,
at,
});
}
}
if (step.view !== undefined && step.view !== '' && !templates.has(step.view)) {
out.push({ code: 'schema-invalid', severity: 'error', message: `剧本引的视图模版不存在:${step.view}`, at });
}
}
return out;
}
export function staticAudit(manifest: ActivityManifest, external: ExternalChecks): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
@@ -423,6 +831,7 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
out.push(...ledgerKindFindings(manifest));
out.push(...acceptanceLevelFindings(manifest));
out.push(...loopFindings(manifest));
out.push(...autoRecipeFindings(manifest));
return out;
}
@@ -76,6 +76,7 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
const verdict: InstallVerdict = {
accepted: mayTakeEffect(staticOk, replay),
stored: false,
staticFindings: findings,
replay: {
oldPassStillPass: replay.oldPassStillPass,
@@ -90,6 +91,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
// 安装恒为新增、版本并存;在途任务按其启动时版本跑完
const isNewPackageVersion = await deps.store.savePackage(manifest, artifact);
// 同版重装 = 什么都没写:如实回给调用方,别让安装脚本再打印「已装」
verdict.stored = isNewPackageVersion;
/*
* 资产初始版随包播种。
@@ -32,3 +32,14 @@
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
活动代码无从自查自守
- **落地失败卡是执行工单的 preset**,不构成第五种卡
- **打不回的验收卡**(2026-09-01 用户定):`AcceptanceWorkOrder.redoable === false` 时,`verdict !== 'pass'` 的回传当场拒(`redo-unavailable`)。什么时候是 false:那一步整个由配方跑完、一个字的 `guide` 都没有 —— 打回之后没有模型会读那句意见、件也只读,配方只会原样重提并撞在「打回之后台账没动过」那道闸上。卡面把钮收起来只是给人看的,**这里才是门**;拒回把真路指出来(别签,去终止重开)。判断在装配层 `releaseRedoable`(纯函数,有测)。
- **人工验收清单点全才能通过**(2026-08-27 用户定):验收工单 / 审核卡带 `checklist`(剧本 `acceptanceChecklist`,
每份工单固定)时,「通过」的回传必须带 `checked ⊇ checklist`,否则 `checklist-incomplete` 拒回;驳回不看清单。
审核卡只拦「通过」那一个选项(`reviewApproveValue`:值叫 approve 的 / 推荐的 / 第一个)。点过的条目落进
`done.checked` / `chosen.checked` 留痕
- **`pendingCards(taskId)`**(2026-08-27):这一单正等人交回的卡 = 没办结的工单 + 没作答、没被顶替的对话卡
(`CardStore.listOpenChatCards` 按 `superseded_by is null` 取)。引擎每拍开头问它:有卡没答就不问模型
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)。
- **验收工单是执行工单的 `variant:'accept'`**(2026-08-31):件只读、人只核对确认(「确认无误」),同样不构成新的 mode。**与 `mode:'acceptance'` 分清**:那个是 `submit` 抬起来的收口验收(签下去落账发口、带打回重做);这个只是「这一步的活干完了,请你过目」,回传即办结。三副面孔的判断在 `apps/web/.../desk-task/internal/order-face.ts`(纯函数,有测)。
**确认即入库**(2026-08-27 下午)不在本模块:回传落定之后由 app 的回传路由调 `landDataOrder`
(`apps/api/src/app/data-order-landing.ts`)把这张卡 `gateLedgers` / 绑的表并进正式表 —— 本模块只管回传本身
@@ -3,11 +3,14 @@ import { describe, expect, it } from 'vitest';
import type {
AcceptanceWorkOrder,
ChatCard,
ChatCardReply,
DeliverableId,
ExecutionWorkOrder,
WorkOrder,
WorkOrderId,
WorkOrderReply,
} from '@contracts';
import { chatCardSchema, workOrderSchema } from '@contracts';
import {
allCriteriaGreen,
arrivalOf,
@@ -92,6 +95,30 @@ describe('工单形态机检', () => {
});
});
/*
* **打不回的验收卡**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
* 因为这里都没有接入 ai」)。卡面把钮收起来只是给人看的,这里才是门 —— 直调 API 交「重做」,
* 单子会被推进死胡同:没有模型读那句意见,配方只会原样重提,撞在「打回之后台账没动过」那道闸上。
*/
describe('打不回的验收卡', () => {
const redo: WorkOrderReply = { mode: 'acceptance', workOrderId: OID, verdict: 'redo', values: {}, comment: '再看看', selectedPorts: {} };
it('redoable:false → 交「重做」当场拒,拒回把真路指出来', () => {
const v = checkWorkOrderReply(acceptance({ redoable: false }), redo);
expect(v.map((i) => i.code)).toContain('redo-unavailable');
expect(v.find((i) => i.code === 'redo-unavailable')?.message).toContain('终止');
});
it('同一张卡「通过」照收 —— 拦的是打回,不是这张卡', () => {
const pass: WorkOrderReply = { mode: 'acceptance', workOrderId: OID, verdict: 'pass', values: {}, selectedPorts: {} };
expect(checkWorkOrderReply(acceptance({ redoable: false }), pass).map((i) => i.code)).not.toContain('redo-unavailable');
});
it('没写这面旗(老卡)照旧打得回', () => {
expect(checkWorkOrderReply(acceptance(), redo).map((i) => i.code)).not.toContain('redo-unavailable');
});
});
describe('到达面两路', () => {
it('负责人的卡进任务对话流', () => {
expect(arrivalOf('s1', 's1')).toBe('task-chat');
@@ -276,3 +303,75 @@ describe('录入卡分段:段号.列名', () => {
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
});
});
/*
* 人工验收清单要点全才能通过(2026-08-27 用户定:「只有人把这些判据都点成达标了,才能点击确认通过」)——
* 按钮灰掉是给人看的,这里是门:验收工单的「通过」与审核卡的「通过」选项同一道。驳回不拦。
*/
describe('人工验收清单:点全才能通过', () => {
const list = ['核对甲', '核对乙'];
// 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转(§12.5)
const order: WorkOrder = workOrderSchema.parse({
id: 'wo-1',
at: '2026-08-27T10:00:00.000Z',
title: '评审放行与落账确认',
basis: [{ k: '来源', v: '收口' }],
mode: 'acceptance',
deliverable: 'landing',
criteria: [],
fields: [],
checklist: list,
});
const pass = (checked?: string[]) =>
({ mode: 'acceptance', workOrderId: 'wo-1', verdict: 'pass', values: {}, selectedPorts: {}, ...(checked === undefined ? {} : { checked }) }) as WorkOrderReply;
it('验收工单:少点一条就拒,点全放行;驳回不看清单', () => {
expect(checkWorkOrderReply(order, pass(['核对甲'])).map((v) => v.code)).toEqual(['checklist-incomplete']);
expect(checkWorkOrderReply(order, pass())).toHaveLength(1);
expect(checkWorkOrderReply(order, pass(list))).toEqual([]);
const redo = { mode: 'acceptance', workOrderId: 'wo-1', verdict: 'redo', values: {}, comment: '再看看', selectedPorts: {} } as WorkOrderReply;
expect(checkWorkOrderReply(order, redo)).toEqual([]);
});
it('审核卡(决策卡):选「通过」那一项要点全,驳回 / 自定义不看', () => {
const card: ChatCard = chatCardSchema.parse({
k: 'decision',
id: 'c1',
at: '2026-08-27T10:00:00.000Z',
ask: '请审核',
basis: [],
options: [
{ value: 'approve', label: '通过', recommended: true },
{ value: 'reject', label: '驳回' },
],
allowCustom: true,
variant: 'review',
checklist: list,
});
const reply = (value: string, checked?: string[], note?: string) =>
({ k: 'decision', cardId: 'c1', value, ...(checked === undefined ? {} : { checked }), ...(note === undefined ? {} : { note }) }) as ChatCardReply;
expect(checkChatCardReply(card, reply('approve')).map((v) => v.code)).toEqual(['checklist-incomplete']);
expect(checkChatCardReply(card, reply('approve', list))).toEqual([]);
expect(checkChatCardReply(card, reply('reject'))).toEqual([]);
// 自定义收尾项本就必附理由(既有规矩);清单不拦它
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
});
});
describe('审核卡的选项数', () => {
it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => {
const findings = checkChatCard({ k: 'decision', ask: '往哪走', basis: [{ k: 'a', v: 'b' }], options: [{ value: 'a', label: 'A' }] } as never);
expect(findings.some((f) => f.code === 'options-missing')).toBe(true);
});
it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => {
const findings = checkChatCard({
k: 'decision',
variant: 'review',
ask: '核对过了吗',
basis: [{ k: 'a', v: 'b' }],
options: [{ value: 'approve', label: '确认通过' }],
} as never);
expect(findings.some((f) => f.code === 'options-missing')).toBe(false);
});
});
@@ -34,6 +34,7 @@ function cardStore(used: number, sent: string[]): CardStore {
},
getChatCard: async () => undefined,
listChatCards: async () => [],
listOpenChatCards: async () => [],
updateChatCard: async () => undefined,
closeChatCard: async () => undefined,
humanCallCount: async () => used,
@@ -143,6 +143,11 @@ export type HumanToolService = {
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
chatCards(taskId: TaskId): Promise<ChatCard[]>;
/**
* **这一单正等人交回的卡**(2026-08-27):没办结的工单 + 没作答、没被顶替的对话卡。
* 引擎每拍开头问它 —— 有卡还没答,这一拍就不该问模型(叫了人就等人,不许自己往下推)。
*/
pendingCards(taskId: TaskId): Promise<Array<{ id: string; title: string; kind: 'work-order' | 'chat-card' }>>;
/** org 停用前置检查要问的「待答卡」 */
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
};
@@ -99,6 +99,15 @@ export function createCardStore(db: Db): CardStore {
return rows.map((row) => chatCardSchema.parse(row.payload));
},
async listOpenChatCards(taskId) {
const rows = await db
.select()
.from(chatCardTable)
.where(and(eq(chatCardTable.taskId, taskId), isNull(chatCardTable.supersededBy)))
.orderBy(asc(chatCardTable.at));
return rows.map((row) => chatCardSchema.parse(row.payload));
},
async updateChatCard(card: ChatCard) {
await db.update(chatCardTable).set({ payload: card }).where(eq(chatCardTable.id, card.id));
},
@@ -89,7 +89,17 @@ export function checkChatCard(card: ChatCard): CardViolation[] {
switch (card.k) {
case 'decision':
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });
/*
* **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 ——
* 改全在件上就地做,卡上只有「确认通过」一个动作。
* 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。
*/
{
const floor = card.variant === 'review' ? 1 : 2;
if (card.options.length < floor) {
out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' });
}
}
break;
case 'elicitation':
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
@@ -123,6 +133,18 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
if (rejectionNeedsComment(reply)) {
out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' });
}
/*
* **打不回的卡就是打不回**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有
* 重做的选项,因为这里都没有接入 ai」)。卡面把那颗钮收起来只是给人看的,**这里才是门** ——
* 直调 API 把「重做」交回来,单子会被推进死胡同(没有模型读那句意见,配方只会原样重提,
* 撞在「打回之后台账没动过」那道闸上)。拒回把真路指出来:别签、去终止重开。
*/
if (reply.verdict !== 'pass' && (order as AcceptanceWorkOrder).redoable === false) {
out.push({
code: 'redo-unavailable',
message: '这一步打不回:它整个由配方跑完,没有模型会读你的意见,件也只读 —— 有异议就别确认,去终止这一单重开。',
});
}
/*
* **恰选一口,随放行一并确认**(AM 1.2)—— 与「驳回必附意见」同族的收单规矩。
* 工单投影自己声明了选择组(portChoice),放行回传就必须带组内恰一口:
@@ -131,6 +153,16 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
* 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑,
* 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
*/
/*
* **人工验收清单要点全才能通过**(2026-08-27 用户定):卡上那几条是人核这份工单要走的步骤,
* 按钮灰掉只是给人看的,这里才是门 —— 直调 API 把「通过」交回来也得把清单点全。
*/
if (reply.verdict === 'pass') {
const missing = checklistMissing((order as AcceptanceWorkOrder).checklist, reply.checked);
if (missing.length > 0) {
out.push({ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` });
}
}
const choice = (order as AcceptanceWorkOrder).portChoice;
if (choice !== undefined && reply.verdict === 'pass') {
const picked = reply.selectedPorts[choice.group];
@@ -154,6 +186,20 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
return out;
}
/** 清单里还没点成「达标」的那几条;没有清单就是空 */
export function checklistMissing(checklist: ReadonlyArray<string> | undefined, checked: ReadonlyArray<string> | undefined): string[] {
const done = new Set(checked ?? []);
return (checklist ?? []).filter((item) => !done.has(item));
}
/**
* 审核卡上哪一个选项是「通过」—— 与前端 `review-face.ts` 同一口径:值叫 `approve` 的,否则推荐的那个,否则第一个。
* 清单只拦这一个选项:驳回不需要把清单点全。
*/
export function reviewApproveValue(card: Extract<ChatCard, { k: 'decision' }>): string | undefined {
return (card.options.find((o) => o.value === 'approve') ?? card.options.find((o) => o.recommended === true) ?? card.options[0])?.value;
}
export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] {
const out: CardViolation[] = [];
if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }];
@@ -161,6 +207,11 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
if (card.k === 'decision' && reply.k === 'decision') {
if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }];
// 审核卡穿验收工单的衣服:选「通过」也得把人工验收清单点全(与验收工单同一道门)
if (reply.value === reviewApproveValue(card)) {
const missing = checklistMissing(card.checklist, reply.checked);
if (missing.length > 0) return [{ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` }];
}
const known = card.options.some((o) => o.value === reply.value);
// 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由)
if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` });
@@ -197,7 +197,13 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
const at = deps.clock.now().toISOString();
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
await deps.cards.markWorkOrderDone(reply.workOrderId, {
at,
by,
result,
values,
...(reply.mode === 'acceptance' && reply.checked !== undefined ? { checked: [...reply.checked] } : {}),
});
/*
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
@@ -365,6 +371,15 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
return deps.cards.listChatCards(taskId);
},
async pendingCards(taskId) {
const orders = (await deps.cards.listWorkOrders(taskId, true)).filter((o) => o.done === undefined);
const chats = (await deps.cards.listOpenChatCards(taskId)).filter((c) => !chatCardAnswered(c));
return [
...orders.map((o) => ({ id: String(o.id), title: o.title, kind: 'work-order' as const })),
...chats.map((c) => ({ id: String(c.id), title: c.ask, kind: 'chat-card' as const })),
];
},
async openCardsOf(staffId) {
return deps.cards.openCardsOf(staffId);
},
@@ -375,7 +390,13 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
if (card.k === 'decision' && reply.k === 'decision') {
return {
...card,
chosen: { value: reply.value, at, by, ...(reply.note === undefined ? {} : { note: reply.note }) },
chosen: {
value: reply.value,
at,
by,
...(reply.note === undefined ? {} : { note: reply.note }),
...(reply.checked === undefined ? {} : { checked: [...reply.checked] }),
},
};
}
if (card.k === 'elicitation' && reply.k === 'elicitation') {
@@ -392,3 +413,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
}
return card;
}
/** 对话卡答没答:决策卡看 chosen,其余(录入 / 补全 / 评价)看 done */
function chatCardAnswered(card: ChatCard): boolean {
if (card.k === 'decision') return card.chosen !== undefined;
return 'done' in card && card.done !== undefined;
}
+2
View File
@@ -17,6 +17,8 @@ export type CardStore = {
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
/** 还开着的对话卡(没被新卡顶替、没作废)—— 「这一单有没有卡在等人」问的是它,不是全部历史 */
listOpenChatCards(taskId: TaskId): Promise<ChatCard[]>;
updateChatCard(card: ChatCard): Promise<void>;
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
@@ -42,7 +42,15 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
expect(msg?.k === 'ai' ? msg.tag : undefined).toBe('guidance');
});
it('验收件标判据、执行件标指导', () => {
it('验收件标判据、执行件标指导 —— 认痕里的 mode,不认标题', () => {
const out = projectChat([
entry('card-sent', { id: 'a', title: '评审放行与落账确认(ACT-GATE-03)', detail: { kind: 'work-order', id: 'wo-a', mode: 'acceptance' } }),
entry('card-sent', { id: 'b', title: '请你验收前先补齐说明', detail: { kind: 'work-order', id: 'wo-b', mode: 'execution' } }),
]);
expect(out.map((m) => (m.k === 'ai' ? m.tag : undefined))).toEqual(['criterion', 'guidance']);
});
it('老痕没有 mode 才退回看标题', () => {
const out = projectChat([
entry('card-sent', { id: 'a', title: '请你验收:定价方案', detail: { kind: 'work-order', id: 'wo-a' } }),
entry('card-sent', { id: 'b', title: '请你执行:补齐说明', detail: { kind: 'work-order', id: 'wo-b' } }),
@@ -258,3 +266,32 @@ describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', (
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
});
});
/**
* **配方的机械痕不进对话区**(2026-08-29 终审 I5,控制端裁定 F2)。
*
* 上一轮为了让配方留痕进工作日志,把 `internal` 整个去掉了 —— 而 `internal` 是
* `machinery()`(工作日志)与本文件(对话区)**共用的一个旗**,去掉它等于同时对两个投影开门:
* 「芯片主数据:写下 2 行」「已把 8 条参数交给模型逐条判定」这类一笔一个气泡跑进了对话区,
* 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。
* 现在配方那几笔照旧标 `internal`(这里挡住),工作日志那一侧另开 `detail.auto` 的例外。
* 形状取自 `auto-runner.ts` 真实写下的 detail。
*/
describe('配方留痕:进工作日志,不进对话区(I5)', () => {
it('project / analyze 那两笔标着 internal —— 一个气泡都不该冒出来', () => {
const out = projectChat([
entry('observation', { id: 'p1', title: '芯片主数据:写下 2 行', detail: { auto: 'S0_bind#1', ledger: 'parts.profile', internal: true } }),
entry('observation', { id: 'p2', title: '已把 8 条参数交给模型逐条判定,拿回 8 条结论', detail: { auto: 'S3#2', model: 'm', internal: true } }),
entry('observation', { id: 'p3', title: '这一步已经发过卡,不再发第二张', detail: { auto: 'S1#2', internal: true } }),
]);
expect(out).toEqual([]);
});
it('say 与「办不下去」那两笔不标 internal —— 它们是说给人听的,照进', () => {
const out = projectChat([
entry('observation', { id: 's1', title: '已核对 3 颗料,2 颗要建档', detail: { auto: 'S0#3' } }),
entry('observation', { id: 's2', title: '配方第 2 笔办不下去:台账地址不在本活动的声明里 —— 交给你处理', detail: { auto: 'S1#2' } }),
]);
expect(out.map((m) => m.k)).toEqual(['system', 'system']);
});
});
@@ -0,0 +1,245 @@
/**
* **「加一行」克隆到的必须是数据行,不是表头行**(2026-08-30,评审逮到的 Critical 回归)。
*
* 背景(ACT-WORK-05 差异评估 Task 7b):`parts.chip_type` / `parts.type_param` 两张表
* 需要在**零草稿行**时也能「加一行」(`activities/part-substitution-assessment/src/views/
* report.ts` 的 `CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE`)。为了让 `ViewContainer.collect()`
* 同时收到「已有的动态行(`data-rows`)」与「一条不受行数所限的静态种子行」两处的输入,
* `data-edit="域.对象"` 挂在了外层 `<table>` 上(而不是只挂在内层 `data-rows` 的
* `<tbody>` 上)。
*
* 副作用:`ViewContainer.bodyOf()`(`packages/ui-kit/src/ViewContainer.tsx:164-167`,
* 单个 `document.querySelector`,按文档序返回第一个匹配)从此解析到**外层 `<table>`**;
* `addRow()`(`:303-323`)接着在这个 host 里找 `tr:last-of-type` —— CSS `:last-of-type`
* 是**相对各自父元素**判定的,`<thead>` 里那个孤零零的 `<tr>` 在它自己的父元素(`thead`)
* 里必然是「最后一个」,若 `<thead>` 在文档序上排在数据行之前,它就会被
* `querySelectorAll('tr:last-of-type')` 第一个命中 —— 克隆出来的是四个 `<th>` 表头文字、
* **零个** `input[data-col]`,还被 `appendChild` 到 `<table>` 底下、不在任何 `<tbody>` 里。
* 按钮点了有反应,结果完全不可用,且不报错。
*
* 修法(`report.ts`,不改 `ViewContainer.tsx`/`view-render.ts` 这两个共享面):把 `<thead>`
* 挪到**两个 `<tbody>` 之后**、`</table>` 之前。CSS 表格布局规范里
* `table-header-group`(`<thead>` 的默认 display)**始终排在最前面渲染,不看它在 DOM 里
* 排第几**(这是 `tfoot` 允许写在 `thead` 后面、`tbody` 前面这条 HTML5 规则背后同一条
* 视觉排版原理)——视觉呈现不变,而 `document.querySelector`/`querySelectorAll` 认的是
* **文档序**,不是视觉序:`<thead>` 排在两个 `<tbody>` 后面,`tr:last-of-type` 的搜索
* 就再也碰不到表头行(表头行在文档序上比所有数据行/种子行都晚,只有当两个 `<tbody>`
* 都彻底没有 `<tr>` 时才轮得到它——而静态种子行保证了这种情况不会发生)。
*
* **这份测试守的是「W05 这两张表此刻的具体 HTML 形状是对的」,不是「addRow/bodyOf 这两个
* 函数本身对不对」——两件事分给了两处测试,缺一不可**(2026-08-30 复审第二轮逼出来的教训:
* 第一版这里的 `findAddRowTarget` 是纯手抄的选择器复刻,复审把真实 `ViewContainer.tsx:305`
* 的 `tr:last-of-type` 换成明摆着错的 `tr:first-of-type` 重跑这 6 条,6/6 全绿零反应——
* 全仓当时没有任何测试真的执行过 `bodyOf`/`addRow`)。
*
* `bodyOf`/`addRow` 现在**已经 `export`**(`packages/ui-kit/src/ViewContainer.tsx`,
* 零行为改动,只是加宽可见性)——**真实函数的行为**由
* `packages/ui-kit/__tests__/view-container-addrow.test.ts` 直接 `import` 它们去测
* (合成的最小 fixture,不碰任何活动的 HTML;那份文件的变异验证证明了它真的会因为
* `ViewContainer.tsx` 里选错选择器而变红)。**这份文件依然不 `import` 那两个函数**——
* 不是 depcruise 拦着(`packages-are-leaves` 只禁 `packages → apps` 这一个方向,没禁
* `apps → packages`),是**类型系统**拦着:`ViewContainer.tsx` 是 `.tsx`(含 JSX),
* 编译它要 `"jsx": "react-jsx"` 这个 compiler option,而 `apps/api/**` 只受
* `tsconfig.node.json` 管——那份配置没有 `jsx` 选项(它是 Node 后端,本不该解析 JSX)。
* 所以这里仍然用**真实的 `renderView()`**(与 report.ts 的 `CHIP_TYPE_TABLE`/
* `TYPE_PARAM_TABLE` 结构逐字节对齐的镜像 fixture,见下面 `CHIP_TYPE_FRAGMENT`/
* `TYPE_PARAM_FRAGMENT` 头注)跑出「服务端会真的吐出什么 HTML」,再在 jsdom 里镜像
* `bodyOf`/`addRow` 那两行选择器(标了对应的源码行号,与 `view-container-addrow.test.ts`
* 里那份文本上完全一致)去读它——这里测的是**渲染结果的形状**,选择器路径本身的正确性已经
* 交给上面那份文件去守,这里不重复承担那份责任。
*/
// @vitest-environment jsdom
/*
* apps/api/** 只受 tsconfig.node.json 管(lib: ES2023,无 DOM——它是 Node 后端,本不该有
* DOM 全局)。这份测试需要真实 DOM(document/HTMLElement)去挂载 renderView() 的输出、
* 复刻 bodyOf()/addRow() 的选择器链——挪去 packages/ui-kit(有 DOM lib)又会撞
* packages-are-leaves(packages/* 不许反向 import apps/*),renderView 只活在 apps/api
* 里。两难之下用 TS 标准机制解:per-file 的三斜线 lib 引用只给这一份文件额外挂上 DOM 的
* 环境类型,不改 tsconfig.node.json 本身(该配置对 apps/api 其余所有文件原样零 DOM)。
*/
/// <reference lib="dom" />
import { describe, expect, it } from 'vitest';
import { renderView } from '../internal/domain/view-render.ts';
/**
* 复刻 `ViewContainer.bodyOf()` + `addRow()` 的选择器链(逐行对照
* `packages/ui-kit/src/ViewContainer.tsx:164-167` 与 `:303-306`)——两函数现在已经
* `export` 了,但这份文件仍不 `import` 它们:`ViewContainer.tsx` 是 `.tsx`(含 JSX),
* 编译要 `"jsx": "react-jsx"`,而 `apps/api/**` 只受 `tsconfig.node.json` 管、没有这个
* compiler option——类型系统这一关过不去,不是 depcruise 拦着。真实函数的行为已经交给
* `packages/ui-kit/__tests__/view-container-addrow.test.ts` 去直接 import 测了,这里
* 只镜像**选择器本身**(与那份文件里的写法逐字一致),在真实 jsdom Document 上跑,
* 用来验证 `renderView()` 吐出来的 HTML 形状。
*/
function findAddRowTarget(doc: Document, address: string): HTMLElement | null {
// 镜像 bodyOf():单个 querySelector,按文档序返回第一个匹配
const host = doc.querySelector<HTMLElement>(`[data-edit="${address}"]`);
if (host === null) return null;
// 镜像 addRow() 的取「克隆样本」那一步:host 内找 tr:last-of-type
return host.querySelector<HTMLElement>('tr:last-of-type');
}
/**
* 与 `report.ts` 的 `CHIP_TYPE_TABLE` 逐字节对齐的镜像(4 列:# / 编号 / 类型名称 / 类型说明;
* `type_id` 只读不挂 `data-col`——自增列不问人;种子行两格 `data-col`:type_name / description)。
* `thead` 排在两个 `tbody` **之后**——这是本测试要钉死的结构。
*/
const CHIP_TYPE_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_type">
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"><tr>
<td class="rn"><span class="jc-rn-num"></span></td>
<td><span class="jc-vcell ro mono" data-slot="type_id" data-blank></span></td>
<td><span class="jc-vcell" data-slot="type_name" data-col="type_name" data-blank></span></td>
<td><span class="jc-vcell" data-slot="description" data-col="description" data-blank></span></td>
</tr></tbody>
<tbody><tr class="jc-row-static">
<td class="rn"><span class="jc-rn-num"></span></td>
<td><span class="jc-vcell ro">系统生成</span></td>
<td><input class="jc-cell" data-col="type_name" placeholder="新类型名称"></td>
<td><input class="jc-cell" data-col="description" placeholder="这个类型是什么(供选型参考)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th data-h="类型编号">编号</th>
<th class="req" data-h="类型名称">类型名称</th>
<th data-h="类型说明">类型说明</th>
</tr></thead>
</table></div>`;
/**
* 与 `report.ts` 的 `TYPE_PARAM_TABLE` 逐字节对齐的镜像(6 列;`type_id` 是下拉
* `data-options`,不是裸文本 —— Task 7b 的另一条修改)。同样把 `thead` 挪到两个 `tbody` 之后。
*/
const TYPE_PARAM_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.type_param">
<tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"><tr>
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" data-edit-del="parts.type_param">×</button></td>
<td>
<span class="jc-vcell"><span data-rows="parts.chip_type" data-by="type_id=type_id"><i data-slot="type_name" data-blank></i></span></span>
<select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>
</td>
<td><span class="jc-vcell" data-slot="param_name" data-col="param_name" data-blank></span></td>
<td><span class="jc-vcell" data-slot="param_desc" data-col="param_desc" data-blank></span></td>
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
<td><span class="jc-vcell" data-slot="param_unit" data-col="param_unit" data-blank></span></td>
</tr></tbody>
<tbody><tr class="jc-row-static">
<td class="rn"><span class="jc-rn-num"></span></td>
<td><select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></td>
<td><input class="jc-cell" data-col="param_name" placeholder="参数名"></td>
<td><input class="jc-cell" data-col="param_desc" placeholder="这个参数为什么要紧"></td>
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
<td><input class="jc-cell" data-col="param_unit" placeholder="单位(选填)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th class="req" data-h="所属类型">所属类型</th>
<th class="req" data-h="参数名">参数名</th>
<th class="req" data-h="参数说明">参数说明</th>
<th class="req" data-h="约束类型">约束(硬/软)</th>
<th data-h="参考单位">参考单位</th>
</tr></thead>
</table></div>`;
describe('parts.chip_type(零行时):「加一行」必须落在种子行上,不是表头', () => {
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
const dynamicTbody = doc.querySelectorAll('tbody')[0];
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
});
it('走一遍 bodyOf()/addRow() 的真实选择器路径:克隆样本落在 tbody 里,不是 thead', () => {
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.chip_type');
expect(target).not.toBeNull();
// 命中的必须是 <tbody> 的直接子行 —— 不是 <thead> 那一行
expect(target?.parentElement?.tagName).toBe('TBODY');
expect(target?.closest('thead')).toBeNull();
// 且它就是那条种子行(class 对得上)
expect(target?.classList.contains('jc-row-static')).toBe(true);
// 含 2 个 input[data-col](type_name / description),不是 0 个(表头行会是 0)
const inputs = Array.from(target?.querySelectorAll('input[data-col]') ?? []);
expect(inputs.map((i) => i.getAttribute('data-col')).sort()).toEqual(['description', 'type_name']);
});
});
describe('parts.chip_type(已有 1 行时):「加一行」克隆的是那条真数据行', () => {
it('走一遍选择器路径:命中动态 tbody 的最后一条真实数据行,不是种子行也不是表头', () => {
const html = renderView(CHIP_TYPE_FRAGMENT, {
'mine:parts.chip_type': [{ type_id: '1', type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器' }],
});
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.chip_type');
expect(target).not.toBeNull();
// renderRows() 消费掉 data-rows 属性 —— 认「不是种子行、且是文档序第一个 tbody(动态那个)的行」
expect(target?.parentElement).toBe(doc.querySelectorAll('tbody')[0]);
expect(target?.classList.contains('jc-row-static')).toBe(false);
expect(target?.closest('thead')).toBeNull();
// 这一行真的带着那条料的值(证明不是表头、不是种子行)
expect(target?.textContent).toContain('DC-DC 降压稳压器');
});
});
describe('parts.type_param(零行时):同款结构,同样要验', () => {
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
const dynamicTbody = doc.querySelectorAll('tbody')[0];
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
});
it('走一遍选择器路径:克隆样本落在 tbody 里、6 个可填格子都在,不是表头(表头会是 0 个)', () => {
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.type_param');
expect(target).not.toBeNull();
expect(target?.parentElement?.tagName).toBe('TBODY');
expect(target?.closest('thead')).toBeNull();
expect(target?.classList.contains('jc-row-static')).toBe(true);
const cols = Array.from(target?.querySelectorAll('input[data-col], select[data-col]') ?? [])
.map((i) => i.getAttribute('data-col'))
.sort();
expect(cols).toEqual(['constraint_type', 'param_desc', 'param_name', 'param_unit', 'type_id']);
});
});
/**
* **这是 Task 7b 修复之前的真实回归**(评审逮到:「parts.type_param 那张更要紧:它的
* 加行功能在你改动之前是好的」)——`data-edit` 只挂在内层 `<tbody data-rows>` 上(不挂
* 外层 `<table>`),`thead` 排在最前面(改回归修复前的原始顺序)。这条测试**不是**用来
* 证明当前代码正确——它证明的是「如果 `data-edit` 回到只挂 `<table>` 且 `thead` 排在最前」
* 这个错误形状,选择器确实会命中表头(0 个 `input[data-col]`)。留着当变异验证的可读记录:
* 不必真的改 `report.ts` 再改回来,这条测试本身就是「变了会怎样」的固化证据。
*/
describe('回归记录:data-edit 挂外层 table 且 thead 排最前 —— 这个错误形状会命中表头', () => {
it('(此形状已不出现在 report.ts 里,这里独立复现,证明它确实是错的)', () => {
const brokenFragment = `<table data-edit="parts.chip_type">
<thead><tr><th>#</th><th>编号</th><th>类型名称</th><th>类型说明</th></tr></thead>
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"></tbody>
<tbody><tr class="jc-row-static"><td class="rn"></td><td><span class="jc-vcell ro">系统生成</span></td><td><input class="jc-cell" data-col="type_name"></td><td><input class="jc-cell" data-col="description"></td></tr></tbody>
</table>`;
const html = renderView(brokenFragment, { 'mine:parts.chip_type': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.chip_type');
expect(target).not.toBeNull();
// 命中的是表头那一行:落在 thead 里,零个 input[data-col]
expect(target?.closest('thead')).not.toBeNull();
expect(target?.querySelectorAll('input[data-col]')).toHaveLength(0);
});
});
@@ -0,0 +1,99 @@
/**
* **可编辑区域套两层 `data-rows` 时,渲染器真产出什么** —— W05 数据工单版面探针
* (2026-08-30,「核心参数值」想做成"一个候选一个 section、section 里铺参数瓷贴"这形状,
* 先问渲染管线真产出什么,再问 `collect()` 拿它怎么办)。
*
* `collect()` 住在 `packages/ui-kit`,这份文件住在 `apps/api`——`ui-kit` 是 tsconfig.web.json
* 那一支(`jsx: react-jsx` + DOM lib),`apps/api` 归 tsconfig.node.json(两者都没有对方要的
* 编译选项);`packages-are-leaves` 也不许 `packages/*` 反向 import `apps/*`。两侧各钉一条、
* 中间用**同一段渲染出来的 HTML 字符串**接力,防止两份夹具各写各的、悄悄漂移。
*
* **这一条只钉 `renderView` 的真实输出**——collect() 拿这段 HTML 怎么归行,
* 见 `packages/ui-kit/__tests__/view-container-collect-nested.test.ts`
* (把下面 `NESTED_RENDERED` 这段字符串原样复制过去当夹具)。
*/
import { describe, expect, it } from 'vitest';
import { renderView } from '../internal/domain/view-render.ts';
/**
* 外层 `data-edit="parts.parameter"` 与外层 `data-rows="mine:sub_eval.part"` 共处一个标签
* (照仓里既有的可编辑表格惯例,如 `<tbody data-rows="..." data-edit="...">`)——
* 每颗候选料复制一个 `<section>`;section 里再嵌一层 `data-rows="mine:parts.parameter"
* data-by="internal_pn=internal_pn"` 铺这颗料自己的参数瓷贴,每瓷贴三格
* (`param_key` / `param_value` / 主键 `internal_pn`)。
*
* 行容器标签特意避开重名(`article` 外层、`div` 内层、瓷贴用 `span`)——
* `data-rows` 的正则按标签名非贪婪找**同名**闭合标签,内层若也叫 `article`
* 会被外层第一个提前撞见的闭合标签截断(`view-render.test.ts` 379 行那条注释记过同一个坑)。
*/
const NESTED_TPL =
'<article data-edit="parts.parameter" data-rows="mine:sub_eval.part">' +
'<section>' +
'<h3 data-slot="internal_pn"></h3>' +
'<div data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn">' +
'<span class="pv">' +
'<input data-col="param_key" data-slot="param_key" data-value/>' +
'<input data-col="param_value" data-slot="param_value" data-value/>' +
'<input data-col="internal_pn" data-slot="internal_pn" data-value/>' +
'</span>' +
'</div>' +
'</section>' +
'</article>';
const NESTED_SCOPE = {
'mine:sub_eval.part': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
'mine:parts.parameter': [
{ internal_pn: 'LM2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' },
{ internal_pn: 'LM2596S-5.0', param_key: 'output_voltage', param_value: '5' },
{ internal_pn: 'LM2596S-5.0', param_key: 'enable_threshold', param_value: '2.5' },
{ internal_pn: 'XL2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' },
{ internal_pn: 'XL2596S-5.0', param_key: 'output_voltage', param_value: '5' },
{ internal_pn: 'XL2596S-5.0', param_key: 'enable_threshold', param_value: '3.0' },
],
};
/**
* `renderView` 真实产出的逐字 HTML —— 与下面第一条用例的断言必须**逐字节一致**。
* 这段字符串原样复制进 `view-container-collect-nested.test.ts` 当夹具(注释互相点名)。
*/
export const NESTED_RENDERED =
'<article data-edit="parts.parameter"><section><h3>LM2596S-5.0</h3><div>' +
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
'<input data-col="param_value" value="4.5~40"/>' +
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
'<input data-col="param_value" value="5"/>' +
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
'<input data-col="param_value" value="2.5"/>' +
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
'</div></section><section><h3>XL2596S-5.0</h3><div>' +
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
'<input data-col="param_value" value="4.5~40"/>' +
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
'<input data-col="param_value" value="5"/>' +
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
'<input data-col="param_value" value="3.0"/>' +
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
'</div></section></article>';
describe('renderView:外层 data-edit+data-rows 共处一个标签,内层 data-rows+data-by 铺瓷贴', () => {
it('产出两个 <section>(host = <article> 的直接子元素),各自嵌一层 <div> 才到瓷贴 —— 逐字节核对', () => {
const out = renderView(NESTED_TPL, NESTED_SCOPE);
expect(out).toBe(NESTED_RENDERED);
});
it('变异验证:换成 3 颗候选料,输出的 <section> 数跟着变成 3 —— 证明上面的断言真的在读结构,不是凑巧写对了字符串', () => {
const out = renderView(NESTED_TPL, {
'mine:sub_eval.part': [{ internal_pn: 'A' }, { internal_pn: 'B' }, { internal_pn: 'C' }],
'mine:parts.parameter': [
{ internal_pn: 'A', param_key: 'k1', param_value: 'v1' },
{ internal_pn: 'B', param_key: 'k1', param_value: 'v1' },
{ internal_pn: 'C', param_key: 'k1', param_value: 'v1' },
],
});
expect(out.split('<section>')).toHaveLength(4); // split 出 [头, s1, s2, s3]
});
});
@@ -374,3 +374,185 @@ describe('data-file-of:这一格点开是一份资料文件', () => {
expect(out).not.toContain('onclick="x"');
});
});
/*
* 嵌套行模版 + `data-by` 父键过滤(2026-08-27,W05 差异评估结论审核「一个候选一张表」逼出来的):
* 外层按候选逐个复制,里层只列该候选名下的明细 —— 从前渲染器只认一层,里层的 data-rows 原样漏到成品里。
*/
describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
// 外层容器用 <article>:行模版是 <section>,里面不会再出现 <article>(行容器的标签名不得在行模版内部再出现)
const html =
'<article data-rows="rel"><section><h2 data-slot="pn"></h2>' +
'<tbody data-rows="items" data-by="rel_id"><tr><td data-slot="name"></td><td data-slot="pn"></td></tr></tbody>' +
'</section></article>';
const scope = {
rel: [
{ rel_id: 'R1', pn: 'XL' },
{ rel_id: 'R2', pn: 'AP' },
],
items: [
{ rel_id: 'R1', name: '输出电压' },
{ rel_id: 'R2', name: '使能逻辑' },
{ rel_id: 'R1', name: '工作温度' },
],
};
it('里层只留与外层同键的行;父行的槽位仍填父值;data-by 属性随之消费', () => {
const out = renderView(html, scope);
const [first = '', second = ''] = out.split('</section>');
expect(out.split('<section>').length - 1).toBe(2); // 一个候选一个 section
expect(first).toContain('<h2>XL</h2>');
expect(first).toContain('<td>输出电压</td>');
expect(first).toContain('<td>工作温度</td>');
expect(first).not.toContain('使能逻辑');
expect(second).toContain('<h2>AP</h2>');
expect(second).toContain('<td>使能逻辑</td>');
expect(second).not.toContain('输出电压');
expect(out).not.toContain('data-by');
expect(out).not.toContain('data-rows');
});
it('里层槽位与父行同名时取里层那一行(先展开里层,再填父行)', () => {
const out = renderView(html, { ...scope, items: [{ rel_id: 'R1', name: 'n', pn: '子' }] });
expect(out).toContain('<h2>XL</h2>');
expect(out).toContain('<td>子</td>');
});
it('data-by="子列=父列":两边列名不同也对得上 —— 当查表用(整表里挑这一颗料的那一行)', () => {
const out = renderView(
'<ul data-rows="rel"><li><b data-slot="pn"></b><span data-rows="profile" data-by="internal_pn=pn"><i data-slot="brand"></i></span></li></ul>',
{ rel: [{ pn: 'XL' }, { pn: 'AP' }], profile: [{ internal_pn: 'AP', brand: 'Diodes' }, { internal_pn: 'XL', brand: 'XLSEMI' }] },
);
expect(out).toContain('<b>XL</b><span><i>XLSEMI</i></span>');
expect(out).toContain('<b>AP</b><span><i>Diodes</i></span>');
});
it('父值缺席一行不留;没有 data-by 的里层照旧全复制', () => {
const none = renderView(html, { ...scope, rel: [{ rel_id: '', pn: 'X' }] });
expect(none).toContain('<h2>X</h2>');
expect(none).not.toContain('<td>');
const all = renderView(html.replace(' data-by="rel_id"', ''), scope);
expect(all.split('使能逻辑').length - 1).toBe(2);
});
/*
* **已知问题,尚未修 —— 2026-08-30(ACT-WORK-05 task-12,缺陷①,浏览器真人走查逮到)**。
* 现场报的是「候选料一行的芯片类型只读态显示着一个值,而那一格实际是空的,编辑态与 SQL
* 双重核实过是空串」。原本怀疑是 `byParent` 对空父键的处理退化成显示第一行 —— 查下来这
* 个猜测是错的:上面「父值缺席一行不留」那条已经钉死了"这一行自己把父键显式写成空字符串"
* 这个场景(`rel_id: ''`),`byParent` 的 `parent === ''` 守卫恒定正确,不是这次的病根。
*
* 真正能复现的是**另一种缺席**:这一行压根**没有**这个字段(不是空字符串,是键都不存在)——
* 常见于必填但还没填的草稿行(如 ACT-WORK-05 的 input-supplement 件:候选料还没定型号时,
* `sub_eval.part` 那一行不会带 `chip_type_id` 这个键)。`renderRows` 递归处理嵌套
* `data-rows` 时用 `{ ...scope, ...one }` 合并外层与本行 —— `one` 缺席的键会**悄悄落回
* 外层 scope 里同名的字段**。而外层 scope 常常真的有一个同名字段:`engines.ts` 的
* `taskData.view()` 会把「各对象最新一行的字段名」铺到顶层(`task.data`),
* `part-substitution-assessment` 的 `case-model.ts` 就因为撞过这同一个坑,自己的 `auto`
* 配方特地避免用 `{in:'chip_type_id'}`(该文件 134 行注释:「这个名字在三张表上都有,
* 顶层同名以后写的赢」)—— 但那条护栏只保护了 case-model 自己的 S-FEEL 求值,没有覆盖到
* 这里的 `data-by` 嵌套查表,于是同一类撞名在 `view-render.ts` 这一侧原样重演。
*
* 用 `it.fails`:这条断言此刻确实会失败(已用最小复现核对过,不是这条测试自己写错)——
* 先把复现钉进套件,不在这一轮改 `view-render.ts`(共享面,E04/I01 也用 `data-by`,
* 改动需要先报给设计者裁定修法);等修完翻正、去掉 `.fails`,它就是永久回归测试。
*/
it.fails('父键在本行里压根缺席(不是空字符串)时,不该被外层作用域里恰好同名的字段顶替', () => {
const withNestedLookup =
'<tbody data-rows="parts"><tr><td data-slot="pn"></td>' +
'<span data-rows="dict" data-by="id=type_id"><i data-slot="name"></i></span>' +
'</tr></tbody>';
const out = renderView(withNestedLookup, {
type_id: '5', // 页面顶层恰好有个同名字段(如 sub_eval.case.chip_type_id 铺到顶层的产物)
parts: [{ pn: 'XL2596S-5.0' /* 这一行没有 type_id 这个键 —— 该型号还没定型 */ }],
dict: [{ id: '5', name: 'DC-DC 降压稳压器' }],
});
expect(out).not.toContain('DC-DC 降压稳压器');
});
});
describe('data-options:下拉的选项从台账现读', () => {
const scope = {
'parts.chip_type': [
{ type_id: 1, type_name: 'DC-DC 降压稳压器' },
{ type_id: 2, type_name: 'LDO 线性稳压器' },
],
chip_type_id: 2,
};
it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => {
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
const out = renderView(html, scope);
expect(out).toContain('<option value=""></option>');
expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
});
it('选项文字要转义 —— 台账里的值是人填的', () => {
const out = renderView(
'<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
{ 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
);
expect(out).toContain('&lt;b&gt;坏&lt;/b&gt;');
expect(out).not.toContain('<b>坏</b>');
});
it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
expect(out).toContain('<option value=""></option>');
expect(out).not.toContain('undefined');
});
it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
const html =
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
'</tbody>';
const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
});
/*
* 评审逮到的渗透:「块里有没有 selected 这个词」防不住重复处理 ——
* 一行被正确处理过、且合法地落在「没有选中项」上(值不在选项里 / 这一格还没填),
* 跟「压根没被处理过」在字面上是同一个样子:都没有 selected 这个词。
* `renderView` 收尾那一遍拿的是外层作用域,这里的外层 `chip_type_id: 2` 恰好命中
* 选项列表里的 LDO —— 若防重失灵,它会被错误地渗进这一行,而这一行自己的数据
* 说的明明是「没有选中」。
*/
const rowHtml =
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
'</tbody>';
it('行内下拉:该行的值不在选项里 —— 不许被外层同名的值渗进来标 selected', () => {
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{ chip_type_id: 99 }] });
expect(out).not.toContain('selected');
});
it('行内下拉:新加的空行(这一列还没填)—— 同样不许被外层同名的值渗进来', () => {
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{}] });
expect(out).not.toContain('selected');
});
it('行外的独立下拉仍按顶层作用域正确标好 selected(防重不能把这条也防没了)', () => {
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
const out = renderView(html, scope);
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
});
});
describe('孪生件占位提示:data-ph 落成 input 的 placeholder(2026-08-31,W05 参数瓷贴)', () => {
it('带 data-ph 的可改格,孪生 input 有 placeholder;不带的没有', () => {
const html =
'<div data-rows="mine:parts.parameter" data-edit="parts.parameter"><p>' +
'<span class="jc-vcell" data-slot="param_value" data-col="param_value" data-ph="规格书上的值,查不到留空" data-blank></span>' +
'<span class="jc-vcell" data-slot="param_note" data-col="param_note" data-blank></span>' +
'</p></div>';
const out = renderView(html, { 'mine:parts.parameter': [{ param_value: '', param_note: '' }] });
expect(out).toContain('placeholder="规格书上的值,查不到留空"');
const notePart = out.slice(out.indexOf('data-col="param_note"'));
expect(notePart.slice(0, notePart.indexOf('/>'))).not.toContain('placeholder=');
});
});

Some files were not shown because too many files have changed in this diff Show More