This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/adr/ADR-014-deterministic-steps.md
T
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

361 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-014 确定性步骤:确定的活由引擎按声明做,不问模型
状态:**已实现**(2026-08-29;设计见 `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §5)
背景出处:ACT-WORK-05 差异评估九步流程盘点(§2)、`docs/ACT-WORK-05-端到端测试记录.md` 发现表 A–AE。
关联:ADR-004(活动包 ABI:**系统不执行代码即知包为何物**)、`docs/活动开发规范.md` §4.4(自动收步)、
`PlanStep.humanWrites`(2026-08-28 立的墙)、ADR-005(工具契约与落地预检)。
---
## 1. 问题
用户 2026-08-28 的原话:
> 「这个不是一个确定的流程吗?根本不需要交给 ai,你直接查库不就知道有没有了吗?」
指的是 W05 的「逐个料号查库 → 不在库就铺骨架、发数据工单」。这一段没有任何判断余地,却写成了模型的 guide。
不是活动作者偷懒 —— **平台里没有别的落点**:
| 现有机制 | 能做 | 不能做 |
|---|---|---|
| 纯工具(`pure`) | 算、读台账(1.2.0 起) | 写台账、发卡 |
| 判定表(DMN) | 出结论 | 写台账、发卡 |
| `doneWhen` | 判「办完没有」,引擎自己收步 | **做事** |
| 哨兵(`entryCriteria`) | 判「能不能进这一步」 | **做事** |
| 视图绑定语言 | 把台账取出来**摆到屏上** | 写回去 |
「读台账 + 写草稿 + 发人卡」跨了三样,没有一个机制能整段承接。而引擎这三样能力**全有**
(`data.view` / `data.draftWrite` / `humanTool.issueExecution`)—— 缺的只是**声明面**。
`packages/activity-sdk/src/define.ts` 里 `run?` 的注释早就写着:
> 留着它是为**确定性子序列(结晶片段)**将来有个落点;不写就是不写,不是缺件。
这份 ADR 就是给那个落点定形。
**代价已经付过。** W05 的事故里,至少八条落在「本来就该是确定的」那几步上(§7 逐条验算)。
---
## 2. 盘点:W05 九步里哪些是确定的
| 步 | 确定的部分 | 需判断的部分 |
|---|---|---|
| **S0_bind** 建上下文 | 铸案号 · 查客户在不在库 · 写需求单(入参搬运+常量)· 逐料号查库写核对表 · 写结论行 | **零** |
| **S0d_p08** 派主数据单 | 定名单 · 铺三张表骨架 · 发卡 · 人交回后改旗 | 有 datasheet 附件时从原文抽参数值 |
| **S0d_p10** 派资料单 | 定名单 · 发卡 · 人交回后改旗 | **零** |
| **S1** 评估输入清单 | 清单九行(四行绑定投影 + 两行固定缺口 + 三行常量默认)· 发执行单 · 把 supplement 抄进需求单 | 人补的自由文本**归纳**成技术约束 |
| **S2** 标准化对照表 | 关系骨架 · 逐参数对照行(原 PN 参数集 × 候选同名行的 join)· 补封装/生命周期两行 | 单位换算;`constraint_type` 未标时归类(**应进 DMN,不进配方**) |
| **S3** 逐项差异评估 | 工具串接 · 四态照抄 · 计数 · `difference_summary` · 置信取最小 | **客户原话 → 约束式** · 影响陈述 · UNKNOWN 去向与验证条件 · 推荐理由扩写 |
| **S4** 结论审核 | 发决策卡(内容是计数投影)· approve 收步 | **读人的自由文本意见 → 定位到具体行 → 改实** |
| **S5** 拟落账 | 分析状态推 FAE 确认 · 回写需求单五项(风险取最高档、VAL 号拼接、编号、常量三项) | 只有 summary 一句人话(可模板化) |
| **T_release** 放行 | submit · 无分支去向 | 两条裁量判据 · 打回后修订 |
**agent 真正不可替代的只有四类**:①自然语言 → 结构化;②读人的意见并落到具体行;③写给人看的话;
④非结构化来源抽取(datasheet 抽值、单位换算)。第四类在实跑中一次都没发生过(没传附件)。
**结论:九步里三步整个确定(S0_bind / S0d_p10 / S5),四步八成确定,只有 S3 是 agent 的主场。**
按拍数算,一单十来拍里有七八拍在做确定性的活 —— 而每一拍都是一次采样,都有编错号、抄错行、漏一列的机会。
---
## 3. 决策
### 3.1 声明式配方,**不执行活动包代码**
`sys.activity_package.artifact`(包的 `index.js`)存了库,但**至今一次都没被执行过**。这份设计**不开这条路**:
- ADR-004 的立场是「系统不执行代码即知包为何物」;配方是**数据**,装进来仍然只是数据,
可静态审、可回放、可在治理台上摆出「这一步系统会做什么」。执行代码则相反。
- 执行第三方包要配沙箱、超时、内存墙,而 `scanArtifact` 只是文本扫描,拦不住混淆。
- 配方要表达的形状**很窄**(§3.2 三个动词),不值得为它开一个能跑任意代码的面。
**代价**:表达力有上限。上限之外的**不要硬塞** —— 退回模型,或者把那一段切成独立的 agent 步(§3.4)。
### 3.2 三个动词,复用已有的两套求值
配方只有三个动词:
| 动词 | 做什么 |
|---|---|
| `project` | 从「某个来源的每一行」投影出台账行,`draftWrite` 落草稿层 |
| `card` | 发一张人类工具的卡(执行工单 / 数据工单 / 数据录入卡),**至多一张** |
| `say` | 一句给人看的交代(模板串,不是模型生成) |
条件与聚合**不发明第二套求值**:
- 条件用 **S-FEEL**(`when`,对任务视图求值)—— 与哨兵、`doneWhen` 同一个求值器;
- 计数/聚合用**基座读数**(`_rows` / `_missing` / `_distinct_x` / tally 计数)—— 本来就在作用域里。
### 3.3 `auto` 是「问模型之前先把确定的事做了」,不是「整步替代」
`PlanStep.auto` 是一个属性。引擎在这一步的**每一拍拍首**先跑配方,然后:
- 配方发了卡 → 这一拍到此为止(**零 token**,不问模型);
- 没发卡且这一步**没有 `guide`** → 求 `doneWhen`,真了收步、进下一步(下一步若也是 auto 就接着跑);
- 没发卡且这一步**有 `guide`** → 照常问模型(半 auto:确定的已经做完,模型只面对剩下的判断)。
这样老步骤一个字不改,可以一步一步搬。
### 3.4 作用域:**一个计划项内,到交出控制权为止**
配方跑到「发卡」就结束 —— 与现有 `handsOff` 语义(ask/human/decide/listen/submit 是交出控制权)对齐。
人答回来之后,那一步若还有活,归 `guide`。
**推论(重要)**:一步要么确定,要么不是。**不做「一半 auto」的复杂机制** —— 判断点要先从那一步里切出去。
例:S0d_p08 的「datasheet 抽值」应当移出这一步(归到起单读来件),这一步才变成纯 auto。
**这条逼着活动作者把判断点想清楚,是特性不是限制。**
### 3.5 幂等
配方会被反复进入(人答卡后再 tick),必须幂等:
- **写**:`draftWrite` 本来就是同 PK 覆盖。值没变 → **静默跳过,不留痕、不算空转被拒**
(「写了等于没写」那条判据是拦模型空转的,配方不是模型);
- **发卡**:先看 `worldTouched` —— 发过就不再发。人工激活的 auto 步,**配方发卡就是启动**。
### 3.6 出错 = 交人,不重试、不退回模型
配方抛错(地址不存在、S-FEEL 算不出、卡骨架不合契约)是**活动包的 bug**:如实留痕 + 交人。
**不重试五遍,也不退回模型** —— 退回模型就是让它去凑,那正是这份 ADR 要消灭的东西。
### 3.7 静态审在装的时候把住
`staticAudit` 扩到配方(与 `refersTo` 目标检查同款,写错地址 = 每一单都错):
- `into` / `each` / `lookup` / `joinRows` 的台账地址必须在 `declarations.ledger` 里(与写的授权面同一份名单);
- `fields` 的列名必须在该对象的列声明里;
- `when` 的 S-FEEL 必须能解析,引用的名字要绑得上(`boundNames`,已有);
- 卡骨架的 `view` 必须在 `viewTemplates` 里;
- 同一张表既在 `auto.project` 里又在 `humanWrites` 里 → **警告**(铺骨架合法,但要作者确认列不重叠)。
### 3.8 观测与度量
- 配方的每一笔照常进 `sys.task_memory`,`detail.auto = '<步名>#<第几笔>'`;
工作日志三色里归**系统灰**(已有档,不必新增);
- 治理台的活动详情能摆出「这一步系统会做什么」(配方是数据,渲染得出来)—— 这是相对提示词的一大好处;
- **自主率的分法要认第三档**:现在两档(AI / 人),配方做的事既不是 AI 判的也不是人办的,是系统做的。
**改口**(2026-08-29,实现时踩出来的):`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`
§5.9 写的是「`analyze` 那一笔归 AI 蓝」—— 那句要改口,**它也归系统灰**。理由:那一行记的是
「系统把 N 条参数交给模型、拿回 M 条结论」,发起与落表都是系统做的,模型只提供内容;
内容本身的归属在它落下的台账行上看得见,不必让日志行冒充成「AI 判的」。
连带修了一处会让整条留痕**静默消失**的坑:配方留痕最初用的是 `kind: 'result'` —— 那不在工作日志
的骨架集合里(`result` 是进模型叙事轨迹那一类,不是给人看的那一类);`project` / `analyze` /
「自动收步」那几笔当时又都标了 `internal: true`,会被 `machinery()` 再滤掉一道 ——
**三段各自「正确」,合起来一笔都进不了工作日志**,人打开日志想知道「系统刚做了什么」,
看到的是空的。现在统一改成 `kind: 'observation'` 且不标 `internal`(「已发过卡,不再发第二张」
这一笔仍保留 `internal` —— 它每拍都打一遍,是排障痕,不是给人看的交代)。
---
## 4. 声明形状
```ts
// PlanStep 新增(可选,老活动一个字不改)
auto?: AutoRecipe;
type AutoRecipe = {
/** 配方级局部值:先算一遍,后面所有表达式都能引用({in:'evalRef'})—— 整单只有一个案号,靠它 */
readonly let?: Record<string, AutoExpr>;
/** 按序执行;每一笔可带 when(S-FEEL),假就跳过这一笔 */
readonly do: ReadonlyArray<AutoAction>;
};
type AutoAction =
| { kind: 'project'; when?: string; into: string; from: AutoSource; fields: Record<string, AutoExpr> }
| { kind: 'card'; when?: string; workOrder: 'execution' | 'entry'; variant?: 'data';
title: AutoExpr; gateLedgers?: string[]; view?: string; /* 指导书等静态文本直接写 */ }
| { kind: 'say'; when?: string; text: AutoExpr };
type AutoSource =
| { once: true } // 一行
| { rows: ReadonlyArray<Record<string, AutoExpr>> } // 固定几行(S1 的默认三项)
| { each: string; where?: Record<string, AutoExpr>; // 'in:candidateInternalPns' | 'mine:域.对象' | 'ledger:域.对象'
then?: { each: string; where?: Record<string, AutoExpr> } }; // 第二层,**最多两层**
type AutoExpr =
| string | number | boolean // 常量
| { in: string } // 入参 / case:* / 基座读数 / let
| { self: true } // each 是数组时的本项
| { col: string } | { outer: string } // 本行的列 / 外层那一行的列
| { cat: AutoExpr[] } // 拼接
| { fmt: AutoExpr; as: 'digits' } // 只留数字(铸号)
| { lookup: string; by: Record<string, AutoExpr>; take: string; else?: AutoExpr } // 查另一张表的一列
| { joinRows: string; where?: Record<string, AutoExpr>; take: string; sep?: string } // 多行一列拼成串
| { when: string; then: AutoExpr; else: AutoExpr }; // S-FEEL 条件(可嵌套)
```
**字段按声明顺序求值,已算出的字段进同一行的 S-FEEL 作用域** —— 「先 lookup 出依据,再按依据判 found/absent」
这种写法靠它成立。
### 4.1 例:S0d_p10_document(整步确定,最小的那个)
```ts
auto: {
do: [
{ kind: 'card',
when: 'sub_eval_pn_check_exists_status_absent > 0',
workOrder: 'execution', variant: 'data', view: 'p10-document-create',
gateLedgers: ['parts.document'],
title: { cat: ['数据工单 · 原厂技术资料创建(P-10):',
{ joinRows: 'mine:sub_eval.pn_check', where: { exists_status: 'absent' },
take: 'internal_pn', sep: '、' }] } },
{ kind: 'say', text: '这几颗料的原厂技术资料库里查无记录,已派数据工单请你登记。' },
],
}
// guide 不写。doneWhen 照旧:parts_document_rows >= parts_profile_rows and parts_document_missing = 0
// 人在件上填完确认 → 数据即入库 → 下一拍 doneWhen 成立 → 引擎收步。**模型在这一步一次都不出现。**
```
### 4.2 例:S0_bind 的铸号与存在性核对
```ts
auto: {
let: {
digits: { fmt: { in: 'case:openedAt' }, as: 'digits' },
evalRef: { cat: ['W05-', { in: 'digits' }] },
requestId:{ cat: ['REQ-', { in: 'digits' }] },
},
do: [
// 客户不在库 → 发数据录入卡,这一拍到此为止
{ kind: 'card', when: 'customer_profile_rows = 0', workOrder: 'entry', /* … */ },
{ kind: 'project', into: 'demand.request', from: { once: true },
fields: { request_id: { in: 'requestId' }, customer_code: { in: 'customerCode' },
requirement_version: 'V1', request_type: '技术需求',
request_description: { in: 'requestDescription' },
input_snapshot_ref: { cat: ['SNAP-', { in: 'evalRef' }] },
original_internal_pn: { in: 'originalInternalPn' },
request_status: 'pending', current_activity: 'ACT-WORK-05', current_stage: 'assessing' } },
{ kind: 'project', into: 'sub_eval.pn_check', from: { each: 'in:candidateInternalPns' },
fields: { eval_ref: { in: 'evalRef' }, internal_pn: { self: true }, role: 'candidate',
found_pn_ref: { lookup: 'ledger:parts.profile', by: { internal_pn: { self: true } },
take: 'internal_pn', else: '' },
exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' } } },
// 原 PN 那一行同理(from: { once: true },role: 'original')
{ kind: 'project', into: 'sub_eval.verdict', from: { once: true }, fields: { /* … */ } },
],
}
```
「在不在库」从此是一次 `lookup`,不是模型的一句断言 —— 08-27 那条「原 PN 不在库却判在库」结构性消失。
---
## 5. 引擎行为(拍内次序)
```
tick 开始
├─ ① 拍首自动收步(现有:doneWhen 为真的项,人工激活项需 worldTouched)
├─ ② 算 current(当下在办的那一步)
├─ ③ current.auto 存在 → 跑配方
│ ├─ 求 let
│ ├─ 按序执行每一笔(when 假则跳过)
│ │ · project → 逐行 draftWrite(同 PK 覆盖;值没变静默跳过)
│ │ · card → 先看 worldTouched,发过就跳过;发了标 worldTouched、烧 humanCalls
│ │ · say → 进对话区
│ ├─ 每一笔留痕(by=系统,detail.auto)
│ └─ 抛错 → 留痕 + 交人(不重试、不退回模型)
├─ ④ 发了卡 → status awaiting,收拍(零 token)
├─ ⑤ 没发卡 → 重求 doneWhen;真了收步,回 ② (封顶 8 轮,与现有自动收步同款)
├─ ⑥ 这一步有 guide → 照常问模型(半 auto)
└─ ⑦ 无 guide、doneWhen 不成立、也没有卡在等 → **交人**(配方没做到位,是包的 bug)
```
**预算**:配方不烧 token;发卡烧 `humanCalls`;配方段在同一 tick 内,不额外计 step。
---
## 6. 与现有机制的衔接
| 机制 | 处置 |
|---|---|
| `doneWhen` | 不变。auto 步几乎都该有 —— 它做的事是可验的 |
| 哨兵 `entryCriteria` | 不变。auto 步照样要满足入场前提才在场 |
| `worldTouched` | 配方发的卡同样标 —— 它是真发了 |
| `humanWrites`(2026-08-28) | **配方豁免**:那道墙防的是模型即兴,配方是审过的声明。铺骨架合法,人填内容不冲突(静态审提示列不要重叠) |
| 写三分 | 不变:配方只写**草稿层**,落正式表仍靠放行 / 数据工单确认入库 |
| 「一单一卡」 | 不变:配方至多一张卡 |
| 熔断 | 配方不会「被拒」,不进 `stalledTurns` |
| 试跑 / 回放 | 配方是纯数据求值,同输入同结果 —— 可回放。**配方里没有时钟与随机**(时刻取 `case:openedAt`) |
| 记账旗 | **该删的删,不是该 auto** —— `input_confirmed_total` 这类基座本就数得出来 |
---
## 7. 回溯验算:历史事故有几条会结构性消失
| # | 事故 | 落在哪 | auto 后 |
|---|---|---|---|
| 1 | 编号从正式表里**别单的案号**里挑 | S0_bind 铸号 | 消失(模板铸号) |
| 2 | 原 PN 不在库却判「在库」(发现 I) | S0_bind 查库 | 消失(lookup 现查) |
| 3 | 数据工单派给了**在库**的那颗(发现 J) | S0d_p08 名单 | 消失(where absent) |
| 4 | 压拍没写骨架就发卡,人打开是空表(发现 Q) | S0d_p08/p10 | 消失(配方按序) |
| 5 | S1 清单 11 行全漏 `input_item`,人无处可填 | S1 前半 | 消失(字段声明齐全) |
| 6 | 库里有值的参数被 S1 当缺口再问一遍(发现 AB,三次复现) | S1 前半 | 消失(清单是投影) |
| 7 | P-10 抄**原 PN 的规格书**安在候选头上(发现 AE) | S0d_p10 | 消失(模型不出现) |
| 8 | `finish` 撞 worldTouched 墙五拍熔断(发现 AE 同单) | S0d_p10 | 消失(那一步不问模型) |
**八条落在会被 auto 掉的三步半上。** 剩下的事故(工具没调、比对入参抄错、验收卡面孔、重做循环)
不在这个范围内 —— 它们该由别的机制治,这份 ADR 不承诺。
---
## 8. 不做什么
- **不调工具**:工具有副作用面与预算面,先不给(S3 的 `compare → recommend` 串接留作待定;
实现时换了个更窄的口子解掉同一个需求,见下方「两处改口」与 §11 待定②的销账)
- ~~**不 `finish`、不 `submit`、不发决策卡**:收步归 `doneWhen`,终局归 submit,岔路口本就是判断~~
(**`submit` 与发决策卡两条已撤**,见下方「两处改口」;`finish` 这条不变 —— 配方从不
显式收步,收步仍旧只有「引擎按 `doneWhen` 自动收」与「半 auto 步问模型、模型 finish」两条路)
- **不写正式表**:仍走草稿层
- **不做三层以上的嵌套**:两层(关系 × 参数)是 W05 的上限;超出就别 auto
- **不在治理台上编辑配方**:那就成了低代码平台;配方随包走,改配方 = 升版本重装
- **不承诺消灭 agent**:§2 的四类判断照旧归它
> **两处改口**(2026-08-29,ACT-WORK-05 重写时用户定):`submit` 与**发决策卡**这两条禁令撤掉。
> 它们原本是**假设还有 agent 在场**时的保守话;而全程无 agent 的活动**没有别人来干这两件事**,
> 不开口最后两步就没人做。护栏没松:`submit` 只许出现在声明了人工验收清单的那一步,
> 提交门(判据全绿)照旧把关 —— **系统提交不比模型提交宽松**;决策卡只在 `variant:'review'` 时
> 放宽到单选项(它不是岔路口,是一道签字关)。
---
## 9. 代价与风险
1. **活动作者要写结构化声明,不能再靠散文。** 但那些散文本来就在(而且更长、还不能测);
配方**能单测**(纯数据求值,包内自测钉死)。
2. **代码错是必然的错,模型错是概率的错。** 配方写错 = 每一单都错。
对策:静态审(§3.7)+ 包内自测 + 安装两审的回放审拿样例入参跑一遍配方。
**但必然的错比概率的错好查** —— 这正是把它从模型手里拿走的理由。
3. **立场要改口。** 「组织的工作定义为可编排的工作流……活动是完成一项任务的 Agent 机制」——
加一句:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。**
确定的事交系统,判断交 AI,责任交人。
4. **把有判断的地方硬写成规则的风险。** `constraint_type` 的 hard/soft 归类按品类会变 ——
这类**进 DMN 判定表,不进配方**;配方里只许出现「取值、拼装、投影、发卡」。
5. **现在的「确定」有一半是靠强制命名一致换来的。** S2 能纯 join,前提是「候选与原 PN 参数名一模一样」
(P-08 建骨架时用中文名硬压 + 门上的行数对账)。**一旦料号来自外部导入**,同义归一就回来了 ——
那是判断,是 agent 的活。**auto 化不等于永远不需要 agent。**
---
## 10. 迁移次序(每搬一步都要五案例 e2e:released + 被拒 0 才算数)
1. **`S0d_p10_document`** —— 最小、纯粹、刚出过事;拿它验证机制(配方 + 静态审 + 留痕 + 观测)
2. **`S5_prepare_landing`** —— 整步确定、不发卡;验证「不发卡时接着走 doneWhen 收步」那条路
3. **`S0_bind`** —— 整步确定、含一张录入卡的分岔;验证 `let` 铸号与 `lookup` 存在性
4. **`S1_input_confirm` 前半** —— 清单投影 + 发卡;半 auto(收口仍归 guide)
5. **`S0d_p08_master`** —— **先把 datasheet 抽值切出去**,再 auto
6. **`S2_normalize`** —— 两层嵌套,最难;搬之前先确认它与比对工具的 join 是否重复(§11)
---
## 11. 待定(要用户定的)
1. **决策卡要不要给配方**:S4 第一张卡的 ask 内容是计数投影,确定;但决策卡是岔路口,给了会不会滑坡?
2. **工具串接要不要做第四个动词**:S3 的 `compare → recommend` 之间模型只是转手 counts,却占掉整整一拍。
3. **`humanWrites` 该不该细到列**:现在是表级。auto 铺骨架 + 人填内容之后,列级更准。
4. **S2 与比对工具的 join 是否重复**:`compare-parameters` 1.2.0 起自己从台账取参数对,
而 S2 让模型抄的 `difference_item` 对照表是**同一份 join**。一份事实做了两遍,一遍还是手抄的 ——
要么 S2 auto 化,要么让工具的出参直接落 `difference_item`(那是另一个机制:工具出参绑台账)。
5. **`T_release` 第三条裁量判据**「推荐等级与差异明细一致」就是工具的定级规则本身,
让模型再裁一次是给自己盖章 —— 该删(与 08-01 在 A1 上拆掉 L4 同款)。