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
This commit is contained in:
xcs
2026-08-28 12:38:26 +08:00
co-authored by Claude Opus 5
parent e1d8d86b16
commit 2cef6f7489
11 changed files with 157 additions and 4 deletions
@@ -656,6 +656,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
*/
/**
* 这张台账「归人填」吗 —— 归人填就返回拒绝理由(剧本 `humanWrites`,见 contracts)。
* 只拦 `write`:`drop` 得留着,不然模型写错了撤不掉。
*/
humanWritesOf?: (itemId: string | undefined, address: string) => string | undefined,
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
): Promise<{
runtime: CaseRuntime;
@@ -920,6 +925,14 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const domain = action.address.slice(0, at);
const object = action.address.slice(at + 1);
/*
* **人填的表,模型不许写**(2026-08-28,用户送来的实测单):数据工单要人在件上补的那几张表,
* 值只可能从人那儿来。实测模型把台账里原 PN 那一行的规格书逐字抄到了候选料号头上 ——
* 它不是凭空编,是「手边有一行看着对的就拿来用」。墙立在这儿,路在拒回里(去发那张卡)。
*/
const humanOnly = humanWritesOf?.(action.itemId, action.address);
if (humanOnly !== undefined) throw new Error(humanOnly);
// 挂项(尽力):阶段不挂(容器)、挂不上不拦 —— 理由随留痕带出,模型下一拍自己修记账
const { parsed: model } = await loaded(taskId);
const target = model.items.find((i) => i.id === action.itemId);
@@ -1929,11 +1942,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
*/
/**
* **这一步还欠一张卡** —— 标了人工激活、而这一单里还没给它发过卡 / 立过监听。
* 此刻它唯一能做的就是把那张卡发出去:别的话都不该说给它听(见下面 doneWhen 那段)。
*/
const owesCard =
current !== undefined &&
parsed.items.find((i) => i.id === current.item)?.manualActivation === true &&
stateRuntime.items.find((i) => i.id === current.item)?.worldTouched !== true;
const currentTouch = ((): string => {
if (current === undefined) return '';
const declared = parsed.items.find((i) => i.id === current.item);
if (declared?.manualActivation !== true) return '';
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
const touched = !owesCard;
/*
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
@@ -1973,7 +1994,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 差的那个数摆出来,这一步就从死胡同变成一句话能办的事:
* 「missing_key_total 现在是 1,要它变成 0」。
*/
current.doneWhen === undefined
/*
* **还欠一张卡的那一步,不摆干完的判据**(2026-08-28,用户送来的实测单)。
*
* 判据本意是「引擎自己核,省一拍」,可它同时是**摆在模型眼前的一个目标**,
* 后面还跟着一句「成立了就 finish」—— 而人工激活的项在卡发出去之前根本不许 finish
* (上面 currentTouch 那句写着「唯一合法的动作是先发那一张卡」)。同一份提示词里两句话
* 直接打架:实测它选了判据那句,把台账里原 PN 的资料抄到候选头上去凑「必填不缺」,
* 然后 finish 连撞五拍墙、熔断。**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑。**
* 卡发出去、人答过之后判据照常摆(那时它本来就该收步)。
*/
current.doneWhen === undefined || owesCard
? ''
: `干完的判据(引擎会自己核):${current.doneWhen}` +
`${readingsOf(current.doneWhen, scope)} —— 成立了就 finish ${current.item},` +
@@ -2201,6 +2232,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
}
},
index < planned.length - 1,
/*
* 归人填的台账:**认行动点名的那一项**,点不出(或那一项没剧本)才退回当下在办的那一步。
* 与 faceOf 同一套判法。压拍时骨架写在上一步名下、填值写在这一步名下,正好各归各的。
*/
(itemId, address) => {
const named = itemId === undefined ? undefined : stepOf(itemId);
const step = named ?? current;
if (step === undefined || !(step.humanWrites ?? []).includes(address)) return undefined;
return (
`${address} 这张表归人填 —— 它是「${step.item}」这一步的数据工单要人在件上补的表(剧本声明 humanWrites),` +
'值只能从人那儿来,你没有来源:别拿别的料号 / 别的行的值顶上(那是假数据,后面会被当证据用)。' +
'这一步该做的是把那张数据工单发出去(human + execution + 数据工单面孔),人填完交回,判据自己就成立了。' +
'已经写进去的错值用 drop 撤掉 —— drop 不拦。'
);
},
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
(itemId) => {
const named = itemId === undefined ? undefined : stepOf(itemId);