diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts
index cf714f2..be70ee8 100644
--- a/activities/part-substitution-assessment/__tests__/steps.test.ts
+++ b/activities/part-substitution-assessment/__tests__/steps.test.ts
@@ -252,6 +252,34 @@ describe('说明书与剧本的骨架', () => {
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);
+ }
+ });
+
+ it('数据工单也要「先写骨架再发卡」写进说明书,且哨兵不认零行主数据(2026-08-27 e2e:模型压拍没写骨架就发卡,人打开是空表,零行同时骗过闸与哨兵,一路走到 S2 才被引用检查拦下)', () => {
+ expect(NARRATIVE).toContain('必须先有骨架行');
+ expect(NARRATIVE).toMatch(/压拍/);
+ // 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了)
+ expect(CMMN_XML).toContain('parts_profile_rows >= sub_eval_pn_check_exists_status_created');
+ });
+
+ it('评估输入那张卡的**前提**写在说明书的卡骨架上(2026-08-27 e2e:两颗缺库,模型只问只建了第一颗,CN 还 absent 就把 S1 的卡发了出去 —— 发卡的授权面是调人预算,绕得过哨兵)', () => {
+ expect(NARRATIVE).toContain('一颗 absent 都不剩');
+ expect(NARRATIVE).toMatch(/有几颗 absent 就要办几颗/);
+ });
+
+ it('发数据录入卡那一拍不许顺手写需求行(客户还没落草稿层,引用检查必拒)', () => {
+ const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
+ expect(g).toMatch(/那一拍只发卡/);
+ expect(g).toContain('引用检查');
+ });
+
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
expect(ASSESSMENT_PHASES).toHaveLength(5);
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts
index 24a5a32..ec78b82 100644
--- a/activities/part-substitution-assessment/src/case-model.ts
+++ b/activities/part-substitution-assessment/src/case-model.ts
@@ -41,6 +41,7 @@ export const CMMN_XML = `
+
@@ -79,7 +80,17 @@ export const CMMN_XML = `
-->
update
- sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0
+
+ sub_eval_pn_check_rows = count(candidateInternalPns) + 1 and sub_eval_pn_check_missing = 0 and sub_eval_pn_check_exists_status_absent = 0 and sub_eval_pn_check_exists_status_abandoned < count(candidateInternalPns) and parts_profile_missing = 0 and parts_parameter_missing = 0 and parts_document_missing = 0 and parts_profile_rows >= sub_eval_pn_check_exists_status_created
@@ -213,7 +224,11 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
\`value\` 只有 \`"create"\` / \`"abandon"\` / \`"custom:"\` 开头三种:\`"custom:"\` 开头按放弃处理、\`note\` 就是放弃原因;
点了 \`"abandon"\` 没写原因,\`abandon_reason\` 写「负责人本轮放弃(未注明原因)」。
-③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上):
+③ 数据工单 · P-08(所有要建的料号一起,一张单;零输入,填报全在工作区的件上)——
+**发这张卡之前,\`parts.profile\` 与 \`parts.parameter\` 必须先有骨架行**(名单里每颗料各一行 + 它的参数各一行)。
+不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时这一步的剧本还没摊开),写骨架与发卡都得在同一串里,顺序不能反 ——
+件上一行都没有的话,人打开是一张空表、什么也补不了,而这一单的主数据从头到尾不会存在
+(实测走到 S2 才被引用检查拦下,那时两张数据工单都已经「办完」了)
{"kind":"human","itemId":"S0d_p08_master","workOrder":"execution","card":{
"title":"数据工单 · 芯片主数据创建(P-08):<料号列表,顿号分隔>",
"ask":"这些料号在芯片主数据里查无记录,请在工作区的件上把整条记录建齐(一颗一行,可加行)。",
@@ -222,8 +237,12 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
"有 datasheet 的把引用与版本一起填(成对);制造商与品牌不一致时分开写",
"改完保存,回这张卡确认 —— 确认后这批主数据即入库,活动从等待数据唤醒"],
"standard":"每颗料号表 1 一行,必填列全齐;表 2 至少含输入电压范围 / 输出电压 / 输出电流 / 工作温度 / 封装相关的硬约束项各一行。",
+ "gateLedgers":["parts.profile","parts.parameter"],
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-08 查无记录(完全没有,不是字段缺失)"},
{"k":"AI 预填","v":"<有 datasheet 就写「已按 datasheet 预填 N 项,请核对」,没有写「未上传 datasheet,请全填」>"}]}}
+**\`gateLedgers\` 必须给**:这是零输入卡,基座不知道这一张要人补哪几张表 ——
+不给它就回落到必交付物那几张(与这张卡无关),于是件上红带点着「缺品牌名称」而提交钮照样绿,
+空手交卷一路放行(2026-08-27 实测)。给了之后,闸与红带、放行门读的是同一个数。
④ 数据工单 · P-10(同一批料号的资料记录,一张单;在 P-08 那张确认之后再发):
{"kind":"human","itemId":"S0d_p10_document","workOrder":"execution","card":{
@@ -233,15 +252,22 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
"同一料号多份资料就多加几行,序号 01/02… 自己续",
"改完保存,回这张卡确认"],
"standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
+ "gateLedgers":["parts.document"],
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}
⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入)——
+**发它的前提:所有料号都已落定** —— \`${D}.pn_check\` 里**一颗 absent 都不剩**(每颗要么 found、要么经数据工单 created、
+要么人点了放弃 abandoned),且建过的那几颗在 \`parts.profile\` 里真有行。还有 absent 就**回去把它们办完**
+(候选总数 ≥2 的逐颗发决策卡问、原 PN 与独苗候选直接建),别先发这张卡:
+硬发出去人也补不成 —— 后面写替代关系时会被引用检查逐颗拦下,而那时数据工单都已经「办完」了(2026-08-27 实测两轮)。
+**有几颗 absent 就要办几颗**,办完第一颗别忘了还有第二颗。
**发这张卡之前,\`${D}.input_gap\` 必须先有行**:清单为空的卡就是让人对着一张空表签字,他补不了任何东西。
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时 S1 的剧本还没摊开),写清单与发卡都得在同一串里,顺序不能反:
已绑定四项(customer / request / original_pn / candidate_N,status="ok")、待补两项(application_scenario / bom_position,status="gap")、
默认三项(substitution_type / rule_version / owner,status="default")、每颗候选的「未披露」参数各一行(status="gap")、原 PN 资料版本(status="ok");
逐列口径见这一步的剧本。
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
+ "gateLedgers":["${D}.input_gap"],
"title":"评估输入与资料补齐确认:<原 PN> → 个候选",
"ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。",
"steps":["应用场景、BOM 位是启动时未收的:必须补(写在「补充内容」列)",
@@ -300,6 +326,8 @@ export const ASSESSMENT_STEPS = [
【客户在不在库】在 \`customer.profile\` 里找 \`customerCode\`:在 → 直接用;不在 → 照说明书①发数据录入卡(名字用 \`customerName\`),
发完这一拍结束;人确认后这家客户就在草稿层里了,下一拍接着做。**客户不在库也不许自己 write customer.profile**。
+**那一拍只发卡,别顺手写 \`demand.request\`** —— 需求行的客户编码有引用检查(客户还没落草稿层,写下去当场被拒);
+等人确认完,下一拍再写需求行与核对表,一次写齐。
【写 \`demand.request\` 一行(V1)】request_id、customer_code(= customerCode)、requirement_version = "V1"、request_type = "技术需求"、
request_description(入参原话,不改写)、input_snapshot_ref、original_internal_pn、request_status = "pending"、current_activity = "ACT-WORK-05"、
@@ -364,6 +392,10 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
brand_name 有就写}(这一行是骨架,下一项 S0d_p10_document 会把它填成正式记录)。
4. 同一拍发说明书③那张 P-08 数据工单(title 里列全料号),发完这一拍结束(挂起)。
+**\`created\` 是「这颗料真的建好了」,不是「我打算建」**:只有它在 \`parts.profile\` 里真有一行(件上填好交回来了)
+才改成 created —— 门上有一条逐颗对账(建好几颗就得有几行主数据),标了却没建,这一步过不去,
+到 S2 写关系时还会被引用检查再拦一次。
+
【P-08 那张确认回来了】这一拍的动作串**恰好两个,顺序不能反**:
① 先 \`finish S0d_p08_master\` —— **必须先把这一项收掉**。不收掉,引擎挑工作区画面时按
「当下还没干完的第一步」取 view,S0d_p08_master 只要还 Active,画面就一直停在
diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts
index 14b82d5..e86fe0a 100644
--- a/activities/part-substitution-assessment/src/manifest.ts
+++ b/activities/part-substitution-assessment/src/manifest.ts
@@ -89,7 +89,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
- packageVersion: '1.2.6',
+ packageVersion: '1.2.9',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',
diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts
index 22e66ed..88b1847 100644
--- a/api-report/packages/contracts/src/card.d.ts
+++ b/api-report/packages/contracts/src/card.d.ts
@@ -857,6 +857,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
cols: z.ZodOptional, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional;
+ gateLedgers: z.ZodOptional>;
gate: z.ZodOptional, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional;
+ gateLedgers: z.ZodOptional>;
gate: z.ZodOptional {
+ 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', {
diff --git a/apps/api/src/app/__tests__/view-table-edit.test.ts b/apps/api/src/app/__tests__/view-table-edit.test.ts
index d7c4089..e3d2f0b 100644
--- a/apps/api/src/app/__tests__/view-table-edit.test.ts
+++ b/apps/api/src/app/__tests__/view-table-edit.test.ts
@@ -19,7 +19,7 @@
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
*/
import { describe, expect, it } from 'vitest';
-import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
+import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
@@ -284,6 +284,36 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
});
+ it('**卡自己声明了 gateLedgers → 看它,不回落到交付物**(2026-08-27 W05 实测:数据工单要补的是 parts.* 主数据,不在必交付物名单里,闸盯着一堆无关的表、一格不缺,空手交卷一路放行)', () => {
+ expect(
+ gateAddressesOf(undefined, [{ ledgers: ['sub_eval.input_gap', 'substitution.relation'] }], ['parts.profile', 'parts.parameter']),
+ ).toEqual(['parts.profile', 'parts.parameter']);
+ });
+
+ it('**卡自报的表一行都没有 = 没完成**(2026-08-27 W05 e2e:模型压拍没写骨架就发了数据工单,件上空空如也,而「缺必填」只数已有行 → 零行零缺、闸照开)', () => {
+ const watched = [
+ { domain: 'parts', object: 'profile', label: '芯片主数据' },
+ { domain: 'parts', object: 'parameter', label: '标准化参数集' },
+ ];
+ const declared = ['parts.profile', 'parts.parameter'];
+ expect(emptyGate({}, watched, declared)?.ready).toBe(false);
+ expect(emptyGate({}, watched, declared)?.why).toContain('芯片主数据、标准化参数集');
+ // 两张都有行 → 不归这一段管,交给「缺不缺必填」那一段
+ expect(emptyGate({ 'mine:parts.profile': [{}], 'mine:parts.parameter': [{}] }, watched, declared)).toBeUndefined();
+ // 一张有一张没有 → 只点没有的那一张
+ expect(emptyGate({ 'mine:parts.profile': [{}] }, watched, declared)?.why).toContain('标准化参数集');
+ });
+
+ it('卡没自报(回落到必交付物那条老路)→ 零行不拦人:那条路上零行是常态', () => {
+ expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).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']);
+ expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], [' parts.profile '])).toEqual(['parts.profile']);
+ });
+
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
expect(gateAddressesOf([], [{}])).toEqual([]);
diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts
index 047b264..cdd536e 100644
--- a/apps/api/src/app/routes/desk.ts
+++ b/apps/api/src/app/routes/desk.ts
@@ -794,33 +794,74 @@ async function submitGate(
if (task === undefined) return undefined;
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
if (manifest === undefined) return undefined;
- const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
+ const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables, order.gateLedgers);
if (addresses.length === 0) return undefined;
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
if (watched.length === 0) return undefined;
+ /*
+ * **卡自己点名要拦的表,「一行都没有」也算没完成**(2026-08-27 W05 e2e 实测)。
+ *
+ * 缺必填的算法只数**已有行**缺不缺格子 —— 零行 = 零缺,闸照开。实测那一单:
+ * 模型压拍,骨架一行没写就把 P-08 数据工单发了出去;人打开件是空的,提交钮却绿着,
+ * 交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被 refersTo 那道墙拦下。
+ * 只对**卡自报**(`gateLedgers`)那几张表这么判 —— 那是发卡的人明说「这张卡就是来填它的」;
+ * 回落到必交付物的老路一个字不变(那条路上零行是常态,比如还没到写的时候)。
+ */
+ const declared = (order.gateLedgers ?? []).map((a) => a.trim()).filter((a) => a !== '');
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
const mine: Record = {};
for (const draft of await container.ledger.taskDrafts(taskId)) {
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
}
- return gateOf(missingReadings(mine, watched), watched);
+ return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
}
/**
- * 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
- * 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
- * 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
- * 而失效的闸在页面上看不出来(按钮照常绿着)。
+ * **卡自报要拦的表里,有没有「一行都没有」的** —— 有就是没完成,别的都不用算了。
+ *
+ * 「缺必填」的算法只数**已有行**缺不缺格子:零行 = 零缺,闸照开(2026-08-27 W05 e2e 实测)。
+ * 那一单模型压拍、骨架一行没写就把数据工单发了出去 —— 人打开件是空的、什么也补不了,
+ * 提交钮却绿着;交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被引用检查拦下。
+ *
+ * 只对**卡自报**(`gateLedgers`)那几张表这么判:那是发卡的人明说「这张卡就是来填它的」。
+ * 回落到必交付物的老路一个字不变 —— 那条路上零行是常态(还没轮到写的时候)。
+ */
+export function emptyGate(
+ mine: Readonly>>,
+ watched: ReadonlyArray<{ domain: string; object: string; label?: string | undefined }>,
+ declared: ReadonlyArray,
+): { ready: boolean; why: string; blanks: string[] } | undefined {
+ if (declared.length === 0) return undefined;
+ const empty = watched.filter(
+ (l) => declared.includes(`${l.domain}.${l.object}`) && (mine[`mine:${l.domain}.${l.object}`] ?? []).length === 0,
+ );
+ if (empty.length === 0) return undefined;
+ const names = empty.map((l) => l.label ?? `${l.domain}.${l.object}`);
+ return { ready: false, why: `${names.join('、')} 这一单还一行都没有 —— 这张卡就是来建它的,先在件上加一行填好再提交`, blanks: [] };
+}
+
+/**
+ * 闸看哪几张表,三级取一:
+ * ①卡上绑了表(`blocks`)就看绑的那几张;
+ * ②**卡自己声明了 `gateLedgers`** 就看它 —— 零输入卡里,只有发卡的人知道这一张卡要人补哪几张表
+ * (2026-08-27 W05 实测:数据工单要补的是 `parts.*` 主数据,不在必交付物名单里,
+ * 闸于是盯着一堆与这张卡无关的表、一格不缺、空手交卷照放);
+ * ③都没有才回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
+ * 三级全空才真的没有依据 —— **没有依据的时候不拦人**。
+ * 抽成纯函数是为了钉测:回落断了闸就整个失效,而失效的闸在页面上看不出来(按钮照常绿着)。
*/
export function gateAddressesOf(
blocks: ReadonlyArray | undefined,
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray | undefined }>,
+ declared?: ReadonlyArray | undefined,
): string[] {
const bound = (blocks ?? [])
.filter((b): b is Extract => b.t === 'table' && typeof b.ledger === 'string')
.map((b) => b.ledger as string);
if (bound.length > 0) return bound;
+ const own = (declared ?? []).map((a) => a.trim()).filter((a) => a !== '');
+ if (own.length > 0) return own;
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
}
diff --git a/apps/api/src/app/tools/first-party.ts b/apps/api/src/app/tools/first-party.ts
index 691efbf..2c8fe52 100644
--- a/apps/api/src/app/tools/first-party.ts
+++ b/apps/api/src/app/tools/first-party.ts
@@ -357,9 +357,20 @@ type ParamPair = {
candidateValue: string;
unit?: string;
constraintType: 'hard' | 'soft';
- requirement?: string;
- evidenceRef?: string;
+ /**
+ * 这两格**模型爱给 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;
@@ -397,9 +408,9 @@ function compareParsed(o: Parsed, c: Parsed): CompareResult {
}
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
-function satisfiesRequirement(requirement: string | undefined, c: Parsed): boolean | undefined {
- if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
- const s = requirement.trim();
+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;
@@ -415,8 +426,8 @@ function satisfiesRequirement(requirement: string | undefined, c: Parsed): boole
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
const LIFECYCLE_ORDER: Record = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
function compareLifecycle(o: string, c: string): CompareResult | undefined {
- const ro = LIFECYCLE_ORDER[o.trim().toLowerCase()];
- const rc = LIFECYCLE_ORDER[c.trim().toLowerCase()];
+ 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';
}
@@ -430,11 +441,11 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
const req = satisfiesRequirement(p.requirement, c);
if (req === false) {
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
- return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${p.requirement ?? ''}` };
+ 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 && (p.evidenceRef === undefined || p.evidenceRef.trim() === '')) {
+ if (hard && text(p.evidenceRef) === '') {
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
}
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts
index 28ece17..3f1aae7 100644
--- a/apps/api/src/app/wiring/engines.ts
+++ b/apps/api/src/app/wiring/engines.ts
@@ -1002,7 +1002,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
throw new Error(
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
- '要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
+ '要指的那一行还不存在的话,先把它真实建出来(该派数据工单的派数据工单、该发数据录入卡的发录入卡),' +
'或者把这一笔挂到已存在的那一行名下',
);
}
@@ -1685,6 +1685,15 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '')
: [];
const standard = String(input.card['standard'] ?? '').trim();
+ /*
+ * **这张卡该拦哪几张表** —— 零输入卡没有 `blocks`,闸只能回落到必交付物,
+ * 而数据工单要人补的是 `parts.*` 这类主数据(不在交付物名单里):闸盯着一堆
+ * 与这张卡无关的表,一格不缺,空手交卷一路放行(2026-08-27 W05 实测)。
+ * 认不出形状的一律丢掉,不把脏东西端给闸。
+ */
+ 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))
+ : [];
const issued = await deps.humanTool.issueExecution(context, {
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
@@ -1693,6 +1702,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...(steps.length === 0 ? {} : { steps }),
...(standard === '' ? {} : { standard }),
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
+ ...(gateLedgers.length === 0 ? {} : { gateLedgers }),
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts
index 02be286..ebff5f4 100644
--- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts
+++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
-import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } from '../internal/usecase/turn-runner.ts';
+import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -992,7 +992,7 @@ describe('宣告完成', () => {
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
* 不是让模型自己判断,是让基座去核。
*/
- it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
+ it('声明了 doneWhen 的项:算出来是假,这一下 finish **不生效**(头一次只落观察,不记被拒)', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
@@ -1005,6 +1005,25 @@ describe('宣告完成', () => {
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
});
+ /*
+ * **早收一次不算撞墙,第二次才算**(2026-08-27 W05 e2e:五案例里三例各白吃一次被拒 ——
+ * 模型把「写行 · 调工具 · finish」排在同一串,工具还没轮到调 finish 先到了,下一拍它自己补上)。
+ * 与「重发已答过的卡头两次无事发生」同一把尺;真在原地打转的单仍看得见(第二次起照旧拒,喂熔断计数)。
+ */
+ it('同一项第二次早收:照旧被拒 —— 熔断要看得见真卡死的单', async () => {
+ const h = harness({
+ parsed: STAGE_MODEL,
+ runtime: READY_RUNTIME2,
+ action: finish('FREE'),
+ steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0' }],
+ sfeelCondition: () => false,
+ trail: [{ kind: 'observation', title: earlyFinishTitleOf('FREE') }],
+ });
+ const outcome = await h.runner.runTurn(TASK);
+ expect(outcome.status).toBe('running');
+ expect(h.appended.some((m) => m.kind === 'result' && m.title.includes('行动被拒'))).toBe(true);
+ });
+
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
const h = harness({
parsed: STAGE_MODEL,
@@ -1704,6 +1723,58 @@ describe('办完的项:话要说对,按一下不报错', () => {
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
*/
+describe('「这张卡人已经答过了」摆成每拍都在的事实行', () => {
+ const sent = (title: string) => ({ kind: 'card-sent', title });
+ it('**决策卡:把人选的那个值摆出来**(2026-08-27 W05 e2e:五个案例四个死在这里 —— 剧本让模型自己去轨迹里翻回传,而那条痕会被挤走)', () => {
+ const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
+ expect(line).toContain('人已经答过了');
+ expect(line).toContain('他选的是「approve」');
+ expect(line).toContain('finish');
+ });
+
+ it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
+ const line = answeredCardFact([sent('评估输入确认'), { kind: 'card-reply', title: '评估输入确认' }]);
+ expect(line).toContain('空答复 = 照原样确认');
+ expect(line).not.toContain('他选的是');
+ });
+
+ it('回传之后又发了**别的**卡 → 有新问题在等,这条不摆', () => {
+ const line = answeredCardFact([
+ sent('第一问'),
+ { kind: 'card-reply', title: '第一问', detail: { value: 'approve' } },
+ sent('另一件完全不同的事'),
+ ]);
+ expect(line).toBe('');
+ });
+
+ it('一张卡都没答过 → 空', () => {
+ expect(answeredCardFact([sent('还没答的卡')])).toBe('');
+ expect(answeredCardFact([])).toBe('');
+ });
+});
+
+describe('doneWhen 为假的拒回:差在哪要说得出口', () => {
+ it('**缺的是一次工具调用就直说** —— 2026-08-27 W05 e2e:五个案例里三个撞上,拒回只教它去查「行与列」,而缺的是 recommend 没调过', () => {
+ const why = unmetWhy('rows > 0 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 0 });
+ expect(why).toContain('缺的不是行也不是列,是一次工具调用');
+ expect(why).toContain('tool_w05_recommend_calls');
+ expect(why).toContain('tool_w05_recommend_calls=0');
+ });
+
+ it('工具调过了(读数 > 0)→ 不点它的名,只把判据与读数摆出来', () => {
+ const why = unmetWhy('rows = 10 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 2 });
+ expect(why).not.toContain('缺的不是行也不是列');
+ expect(why).toContain('rows=9');
+ });
+
+ it('判据里压根没有工具那一档 → 照旧只摆判据与读数', () => {
+ const why = unmetWhy('a_rows = b_rows', { a_rows: 3, b_rows: 4 });
+ expect(why).toContain('a_rows=3');
+ expect(why).toContain('b_rows=4');
+ expect(why).not.toContain('工具调用');
+ });
+});
+
describe('同一张卡再发一遍的三档', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
const replied = (title: string) => ({ kind: 'card-reply', title });
@@ -1717,6 +1788,21 @@ describe('同一张卡再发一遍的三档', () => {
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
});
+ it('**人选的那个值当场摆出来**(2026-08-27 W05 e2e:人点了通过,拒回只给「值在轨迹里」这个指针,模型对不准那一条,连发五次同一张卡熔断)', () => {
+ const win = [sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: 'approve', note: '试跑代答' } }];
+ expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问', chose: 'approve' });
+ const rejected = resendVerdict([...win, echoed('这一问'), echoed('这一问')], '这一问');
+ expect(rejected).toEqual({ kind: 'reject', answered: '这一问', chose: 'approve' });
+ });
+
+ it('回传痕里没有值(空串 / 缺 detail)→ 不编,退回原来那句指针话术', () => {
+ expect(resendVerdict([sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: ' ' } }], '这一问')).toEqual({
+ kind: 'echo',
+ answered: '这一问',
+ });
+ expect(resendVerdict([sent('这一问'), replied('这一问')], '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
+ });
+
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
const win = [sent('这一问'), replied('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
diff --git a/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts b/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts
index fbf35a6..51759bc 100644
--- a/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts
+++ b/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts
@@ -396,7 +396,10 @@ export function criterionKey(raw: string): string {
}
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
-const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
+/** S-FEEL 保留字与**内建函数名** —— 摆读数时不该把它们当成台账名去查:
+ * `count(candidateInternalPns)` 里的 `count` 从前被摆成「count=(没写过)」,
+ * 而那是个函数,不是谁忘了写的一笔账 —— 拒回里多一个假线索,模型就会去追它(2026-08-27 实测)。 */
+const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between', 'count', 'sum', 'min', 'max', 'all', 'any']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts
index 889849f..e2b78e0 100644
--- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts
+++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts
@@ -210,6 +210,30 @@ function rejectReason(title: string): string {
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
* 一模糊就会把「问 A 料」当成「问 B 料」拦掉,那比多问一遍更坏。
*/
+/**
+ * **doneWhen 为假时,「差在哪」要说得出口** —— 拒回的话是模型下一拍的行动指南。
+ *
+ * 从前这条拒回只有一句通用话术:「常见的是该写 N 行只写了 M 行,或某一行缺了必填列」——
+ * 把注意力全导向**行与列**。而收步判据里那半句常常根本不是行也不是列:
+ * `tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条)。
+ * 实测(2026-08-27 W05 e2e,五个案例里三个撞上):模型跳过工具、自己把推荐等级填了,
+ * finish 恒被拒,而拒回教它去查行与列 —— 它只能把同样的明细再写一遍。
+ * **指向两扇打不开的门,比不指还糟。**
+ *
+ * 现在把判据原文与它那几个名字**此刻的读数**一起摆出来(`readingsOf`,与断点提示同一段),
+ * 并且:凡是 `tool_*_calls` 这一族读数为 0 的,单独点名说「缺的不是行也不是列,是一次工具调用」。
+ */
+export function unmetWhy(doneWhen: string, scope: Readonly>): string {
+ const head = ` 干完的判据:${doneWhen}${readingsOf(doneWhen, scope)}`;
+ const idle = [...new Set(doneWhen.match(/tool_[A-Za-z0-9_]+_calls/g) ?? [])].filter((name) => Number(scope[name] ?? 0) === 0);
+ if (idle.length === 0) return head;
+ return (
+ `${head} —— **缺的不是行也不是列,是一次工具调用**:${idle.join(' / ')} 现在是 0,` +
+ '说明这一步该用的工具你还没真调过(调失败不算)。先按剧本调它,拿工具算出来的结果写盘,' +
+ '别自己填那几个值 —— 这一步的收步判据数的就是这枚工具的调用次数。'
+ );
+}
+
export function sameQuestion(a: string, b: string): boolean {
// 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
@@ -229,6 +253,11 @@ export function sameQuestion(a: string, b: string): boolean {
return short.length >= 6 && long.startsWith(short);
}
+/** 「还没到收步的时候」那条观察痕的标题 —— 与「已答过没再发」同族:记与数是同一句话 */
+export function earlyFinishTitleOf(itemId: string): string {
+ return `还没到收步的时候,这一下 finish 没生效(${itemId})`;
+}
+
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
export function echoTitleOf(answered: string): string {
return `这张卡人已答过,没有再发(「${answered}」)`;
@@ -246,16 +275,62 @@ export function echoTitleOf(answered: string): string {
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
*/
export function resendVerdict(
- window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
+ window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record | undefined }>,
asked: string,
-): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
+): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string; readonly chose?: string } {
const trimmed = asked.trim();
if (trimmed === '') return { kind: 'send' };
- const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
+ const replies = window.filter((t) => t.kind === 'card-reply');
+ const replied = new Set(replies.map((t) => t.title));
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
if (same === undefined) return { kind: 'send' };
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
- return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
+ /*
+ * **他选的是什么,当场摆出来**(2026-08-27 W05 e2e 实测的一单熔断):
+ * 人点了「通过」,拒回却只说「值在你的轨迹里(card-reply 那一条)」—— 一个**指针**。
+ * 而模型恰恰对不准那一条(轨迹里躺着好几张卡的 card-reply),于是连发五次同一张卡熔断。
+ * 与 E04 那一课同源:**指南让模型去找的事实,引擎手里有现成的,就直接给它**。
+ * 值就在回传痕的 `detail.value` 里(决策卡的载荷,与 payloadOf 同一约定)。
+ */
+ const value = replies.find((t) => t.title === same.title)?.detail?.['value'];
+ const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
+ return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title, ...(chose === undefined ? {} : { chose }) };
+}
+
+/**
+ * **「这张卡人已经答过了、他选的是什么」——摆成每拍都在的一句事实**(2026-08-27 W05 e2e 逼出来的)。
+ *
+ * 剧本里那句「先在轨迹里找这一项已答过、还没处理的 card-reply」是**让模型自己去翻窗口**,
+ * 而那条回传会被后面的痕挤走:实测五个案例里四个死在同一处 —— 人点了「通过」,
+ * 模型每拍照剧本第一档「没有回音 → 发第一张决策卡」,同一张卡连发五次熔断;
+ * 拒回把「他选的是 approve、接下来该写哪面旗」说得再明白也没用,因为**下一拍它又从头翻一次窗口**。
+ *
+ * 与 worldTouched 那条(2026-08-21)、E04 的「唯引擎事实行是从」(2026-08-24)同一条治法:
+ * **指南让模型判的事,引擎手里有现成事实的,一律改成读事实**。
+ *
+ * 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
+ * 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
+ */
+export function answeredCardFact(
+ window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record | undefined }>,
+): string {
+ let at = -1;
+ for (let i = window.length - 1; i >= 0; i -= 1) {
+ if (window[i]?.kind === 'card-reply') {
+ at = i;
+ break;
+ }
+ }
+ const reply = at < 0 ? undefined : window[at];
+ if (reply === undefined) return '';
+ const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
+ if (newer) return '';
+ const value = reply.detail?.['value'];
+ const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
+ const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
+ return chose === undefined
+ ? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
+ : `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`;
}
/**
@@ -537,7 +612,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
*/
- doneWhenOf?: (itemId: string) => boolean | undefined,
+ doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined,
/**
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
@@ -1135,8 +1210,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (verdict.kind === 'reject') {
throw new Error(
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
- '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
- '照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
+ (verdict.chose === undefined
+ ? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):'
+ : `**他选的是「${verdict.chose}」** —— `) +
+ '照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' +
+ '再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。',
);
}
await deps.memory.append({
@@ -1145,7 +1223,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
title: echoTitle,
detail: {
internal: true,
- note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
+ note:
+ verdict.chose === undefined
+ ? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。'
+ : `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`,
},
});
// 什么也没做,局面原样交回
@@ -1283,13 +1364,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
*/
const doneWhenNow = doneWhenOf?.(action.itemId);
- if (doneWhenNow === false) {
- throw new Error(
+ if (doneWhenNow?.ok === false) {
+ const say =
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
- '不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
- '(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
- '基座会自己收,不必你再 finish。',
- );
+ '不能凭这一下 finish 说它已经办完了。补齐了 doneWhen 自然为真,基座会自己收,不必你再 finish。' +
+ doneWhenNow.why;
+ /*
+ * **早收一次不算撞墙**(2026-08-27 W05 e2e:五案例里三例各白吃一次)——
+ * 与「重发已答过的卡头两次无事发生」同一把尺(设计律:按钮按两下不该变成墙)。
+ * 实测那一形是**计划次序**的小错:模型把 [写行 · 调工具 · finish] 排在同一串,
+ * 工具还没轮到调,finish 先到了;它下一拍自己就补上了 ——
+ * 这种拍不该记成「行动被拒」去喂熔断计数(那是给真卡死留的)。
+ * 头一次只落观察(把判据与差在哪原样摆回轨迹,和拒回一个字不差),
+ * 第二次起照旧拒 —— 真在原地打转的单仍然看得见。
+ */
+ const seen = (await deps.memory.window(taskId, STALL_WINDOW)).filter(
+ (t) => t.kind === 'observation' && t.title === earlyFinishTitleOf(action.itemId),
+ ).length;
+ if (seen === 0) {
+ await deps.memory.append({
+ taskId,
+ kind: 'observation',
+ title: earlyFinishTitleOf(action.itemId),
+ detail: { internal: true, note: say },
+ });
+ return { runtime, status: 'running' };
+ }
+ throw new Error(say);
}
const ready = readyToWork(finishModel, runtime, action.itemId);
await deps.memory.append({
@@ -1745,6 +1846,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
: [
`【当前这一步 · ${current.item}】${current.intent}`,
currentTouch,
+ answeredCardFact(trail),
current.guide,
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
@@ -1976,7 +2078,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const step = stepOf(itemId);
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
try {
- return deps.sfeel.condition(step.doneWhen, liveScope);
+ const ok = deps.sfeel.condition(step.doneWhen, liveScope);
+ return ok ? { ok, why: '' } : { ok, why: unmetWhy(step.doneWhen, liveScope) };
} catch {
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
return undefined;
diff --git a/docs/ACT-WORK-05-端到端测试记录.md b/docs/ACT-WORK-05-端到端测试记录.md
index 1231f37..9043f01 100644
--- a/docs/ACT-WORK-05-端到端测试记录.md
+++ b/docs/ACT-WORK-05-端到端测试记录.md
@@ -3,7 +3,9 @@
> 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。
> 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。
> 台账字段口径见 `docs/数据域字段说明-W05.md`。
-> 全部走查在**浏览器**里做(测试台起单 → 工作台真人点),不是脚本代答。
+> 走查分两条线:**浏览器**(测试台起单 → 工作台真人点,六轮)与**脚本 e2e**
+> (`node tools/e2e-a2.mjs w05`,五案例代答,可一键复跑)。两条线逮到的缺陷不重合 ——
+> 浏览器逮呈现与交互(件、闸、徽标、翻页、画面切换),脚本逮机制(空手交卷、循环、崩、归属)。
## 一、这个活动是什么
@@ -177,6 +179,23 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
版本轨迹:1.0.0 → 1.0.1(A)→ 1.1.0/1.1.1(B·C·D)→ 1.2.0(参数子表)→ 1.2.1(F)→ 1.2.2(G)
→ 1.2.3(H)→ 1.2.4(I)→ 1.2.5(J)→ **1.2.6(K)**。包内自测 16 → **61 条**。
+## 二·六、脚本 e2e 的六轮(2026-08-27)
+
+浏览器那六轮之后,凭证到位、`w05` 套件跑起来,又逼出六条 —— **全是浏览器逮不到的那一类**
+(脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候会先把格子填好再按提交):
+
+| 轮 | 结果 | 这一轮逼出什么 |
+|---|---|---|
+| 1 | 1 通过 | **L 数据工单的提交闸没牙** |
+| 2 | 3 通过 | **M 拒回数不出「缺的是一次工具调用」· N 决策卡拒回只给指针** |
+| 3 | 1 通过 | N 的措辞版没用 → **改结构**;**O 工具被 null 打崩** · P `count` 假线索 |
+| 4 | 2 通过 | **H 的残留面:零行同时骗过闸与哨兵** |
+| 5 | 4 通过 | **created 逐颗对账太松 · S1 卡的前提没写在说明书** |
+| 6 | **跑到的 4 例全 released,3 例被拒 0** | 案例 4 停在 `Insufficient Balance`(大模型余额) |
+
+第 6 轮逐条实证:早收容忍让案例 1/2 的最后 1 次被拒消失(轨迹里是**观察**不是拒回);
+三候选那例**第一次跑通**(前三轮:超时 / 超时 / 烧穿预算),两颗缺库一起办、三条结论全对。
+
## 三、走查发现总表
| # | 现象 | 病根 | 治法 | 层 |
@@ -192,6 +211,14 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
| I | 原 PN 根本不在库,模型两颗都判「在库」,硬阻塞整条路被跳过 | 存在性核对是模型自报的一句话,基座数不出来;剧本已写到头 | 加 `found_pn_ref`(`refersTo` + `requiredWhen`):判在库就必须填一个基座查得到的料号,查无此值当场拒、填不出则收不了步 | 活动(结构) |
| J | 数据工单派给了在库的那颗,缺的那颗没人建 | 剧本把「建哪几颗」与「要不要问人」写在同一串分岔里,从没写过「found 的不建」 | 拆成两个问题分别作答,并写明后果 | 活动 |
| K | 数据录入卡的「参考信息」栏摆着引擎内部家具(九个计划项全名) | `entryCard.basis` 契约里可选,活动没声明,基座兜底把内部事实摆了上去 | 卡骨架补 `basis` 三行,并写明不给的后果 | 活动 |
+| L | **数据工单的提交闸没牙**:件上红带点着「缺品牌名称」,提交钮却是绿的,空手交卷一路放行 | `gateAddressesOf` 只有两级 —— 没绑表就回落到**必交付物**,而数据工单要人补的是 `parts.*` 主数据,不在交付物名单里:闸盯着一堆与这张卡无关的表,一格不缺 | 契约新增 `ExecutionWorkOrder.gateLedgers`(**这张卡该拦哪几张表,发卡的人说了算**),闸改三级取一:绑的表 > 卡自报 > 交付物 | **平台**(结构) |
+| M | `finish` 撞 doneWhen 为假时,拒回只谈「行与列」,而缺的是一次**工具调用** | 拒回从不摆判据本身,更不摆读数 | 拒回摆出**判据原文 + 那几个名字此刻的读数**;`tool_*_calls` 读到 0 的单独点名 | **平台** |
+| N | 人点了决策卡,模型每拍再发一张,连拒五拍熔断 | 剧本让模型「去轨迹里找 card-reply」,而那条痕会被挤走 —— 拒回把「他选的是 approve」写明白也没用,**下一拍它又从头翻一次** | `answeredCardFact`:把「这张卡已答过 → 他选的是什么」做成**每拍都在的事实行** | **平台**(结构) |
+| O | 模型传 `requirement: null`,工具 `null.trim()` 当场崩,整次调用记成「行动被拒」 | 工具入参是宽松描述式 schema,null 一路落到处理器;处理器只判了 `undefined` | 边界上一律经 `text()` 归一,类型如实写成可空 | **平台**(我写的工具) |
+| P | 拒回里摆着 `count=(没写过)` —— 那是 S-FEEL 的函数名,不是谁忘了记的账 | `readingsOf` 把内建函数当台账名 | `SFEEL_WORDS` 补上 count/sum/min/max/all/any | 平台 |
+| Q | 模型压拍没写骨架就发数据工单,人打开是空表,而**零行 = 零缺**,闸与哨兵一起放过 | 「缺必填」只数已有行;`_missing = 0` 在零行时恒真 | 平台 `emptyGate`(**卡自报的表一行都没有 = 没完成**)+ 说明书③「发卡前骨架必须先有行」+ 哨兵 `profile_rows >= created` 逐颗对账 | 平台 + 活动 |
+| R | 两颗缺库只办了第一颗,第二颗还 absent 就把 S1 的卡发了出去 | **发卡的授权面是调人预算,不是计划项** —— 它绕得过哨兵;而 S1 那张卡的**前提**只写在剧本里 | 前提写进说明书的卡骨架(「一颗 absent 都不剩」「有几颗 absent 就要办几颗」) | 活动 |
+| S | 每例白吃一次的「早收」:工具还没轮到调,`finish` 先到了 | 那是**计划次序**的小错,却被记成「行动被拒」去喂熔断计数 | 与「重发已答过的卡」同一把尺:**头一次只落观察,第二次起才算被拒** | **平台** |
**可推广的三条**(已记 `docs/debt.md`):
@@ -199,6 +226,9 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
2. 拒回文案只谈行与列,数不出「缺的是一次工具调用」(发现 G);
3. 执行工单绑的必交付台账**一行都没有**时提交闸无牙 —— 回落只数「已有行缺不缺必填」,零行 = 零缺(发现 H)。
+发现 L 是这三条里**唯一当场治了平台的**:闸看的表与件上摆的表不是同一张,闸就等于没有 ——
+而这件事只有脚本 e2e 逮得到(它每张卡都先空手交一次;人手点的时候会先把格子填好再按提交)。
+
**还有一条**(平台侧,已记 debt):`disable` 这条路从来没通过(发现 A)——
`Disabled` 只能从 `Enabled` 来,而 `Enabled` 只由 `plan` 动作写。
@@ -222,3 +252,13 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
- 落账逐单 SQL 复核:P-11 主记录 / 差异明细 / P-01 回写 / 验证任务 / 主数据 P-08 与 P-10,值与业务一致;
- **只读边界守住**:`customer_approval_status` 每单都留空(本活动不产生),`rule_version` 随结论落账;
- 出口只有一口 `assessment-released`,每单都真发射了。
+
+4. **两条线逮到的缺陷不重合,缺一条就漏一半。**
+ 浏览器逮的是**人看见什么、点得动什么**(件、闸、徽标、翻页、画面切换、快照 1:1);
+ 脚本逮的是**机制**(空手交卷、循环、崩、归属)。发现 L–S 八条没有一条是浏览器走查逮到的 ——
+ 因为人手点的时候会先把格子填好再按提交,永远不会去试「空着交上去会怎样」。
+
+5. **拒回喂事实,不喂指针。**
+ 发现 N 那一轮把「他选的是 approve、下一步写哪面旗」写进拒回,日志里逐字可见,模型照发不误 ——
+ 因为**下一拍它又从头翻一次会滚走的窗口**。把同一件事做成「每拍都在的事实行」之后,一次就好了。
+ 同一条道理已经在 worldTouched(2026-08-21)、E04 的 worldTouched 事实行(2026-08-24)上各现过一次。
diff --git a/docs/活动开发规范.md b/docs/活动开发规范.md
index 9d7ecdd..89fcdfb 100644
--- a/docs/活动开发规范.md
+++ b/docs/活动开发规范.md
@@ -165,7 +165,7 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in
| 门 | 在哪 | 规则 |
|---|---|---|
| **起单宽进** | `process.start` | 不按 Schema 拒缺项。三活动 `input.required` 一律空(宽进严出),起单补全卡那条老路实际不再触发(彻底移除记 debt) |
-| **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表:卡上绑了 blocks 看绑的;**没绑(执行卡零输入后是常态)回落到必交付物声明的 `ledgers`/`ledger`**(`gateAddressesOf`,有测钉着)。两边都空才不给闸 —— 没有依据的时候不拦人 |
+| **执行卡提交闸** | 卡面 `gate` + 服务端 | 必填缺着按不下提交(`{ready,why,blanks}`,与放行门 L1 **同一个数同一套算法**);**服务端在收下回传之前也把一遍**(前端灰按钮只是给人看的)。看哪几张表**三级取一**(`gateAddressesOf`,有测钉着):①卡上绑了 blocks 看绑的;②**卡自己声明的 `gateLedgers`**(零输入卡该给的就是它 —— 只有发卡的人知道这一张要人补哪几张表);③回落到必交付物声明的 `ledgers`/`ledger`。三级全空才不给闸 —— 没有依据的时候不拦人。**主数据类的数据工单必须给 `gateLedgers`**:它要补的表(`parts.*`)不在必交付物名单里,不给就回落到一堆无关的表、一格不缺,件上红带点着缺项而提交钮照样绿(2026-08-27 W05 e2e 实测) |
| **提交门**(`submit` 预检) | engines | 机检(L1/L2)**必须为真**;裁量(L3/L4)**裁过即可**(裁成「不满足」照样能提交,裁断连理由随卡交给人 —— 一条只接受「是」的判据是一句教模型说谎的提示);**L4 带谓词时谓词说了算**(谓词真 = 已达标,模型那句相反的理由一并丢掉;谓词假才轮到裁量);必交付物按 ledger 地址数本单草稿行 |
| **放行门入场哨兵** | CMMN sentry | **必须有 ifPart,且读基座数、认「办完了没有」** —— 只有 onPart 的哨兵等于「有人写过一笔就开门」,模型写个 `false` 就推开了(实测:跳过三步直达放行门) |
| **熔断** | Harness | 连续 5 拍行动被拒 → 停问模型,发熔断卡把拒绝理由摆给人(比对削掉串位前缀、另开 60 条窗) |
@@ -252,7 +252,7 @@ activities//src/ ──(pnpm activities:build)──> dist/{manifest.json,in
| `ask` | ~~已退役~~ | 基座原地转成零输入执行工单(字段名并进指导书);别再用 |
| `entry` | `{itemId, ask, ledger, entryKey, fields, also?}` | 数据录入卡,挂起;确认那一下基座直写 |
| `decide` | `{itemId, ask, options(≥2), basis}` | 决策卡,挂起 |
-| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住(闸回落到必交付物的台账声明) |
+| `human` | `{itemId, workOrder, card{title,ask,steps?,standard?,basis}}` | 执行/验收工单,挂起。**执行卡零输入**(2026-08-02 用户定):不摆 `fields` 不绑 `blocks`,填写全在工作区的件上(editGate + 孪生件),人按「保存」基座直写草稿层;必填由提交闸看住 —— **卡上加 `gateLedgers:["域.对象"]` 告诉闸这一张要拦哪几张表**,不给才回落到必交付物声明 |
| `listen` | `{itemId, eventType, keyFilter, deadline}` | 立监听,挂起;时限必配 |
| `finish` | `{itemId, rationale}` | 宣告一项完成;非人工激活的项就地过 Active 再收 |
| `submit` | `{rationale, fields?}` | 提交放行 → 预检 → 发验收卡挂起 |
diff --git a/packages/contracts/src/card.ts b/packages/contracts/src/card.ts
index e916918..de59ac3 100644
--- a/packages/contracts/src/card.ts
+++ b/packages/contracts/src/card.ts
@@ -356,6 +356,18 @@ export const executionWorkOrderSchema = z.object({
* 不是活动自报的数,也不由前端猜。算不出来(这张卡没绑表)就不给这一格,
* 按钮照常能按:**没有依据的时候不拦人**。
*/
+ /**
+ * **这张卡该拦哪几张台账**(发卡的人说了算)—— 不给就照旧回落到必交付物声明。
+ *
+ * 起因(2026-08-27 W05 实测):数据工单是零输入卡(没有 `blocks`),闸于是回落到
+ * **必交付物**的那几张表 —— 而数据工单要人补的是 `parts.profile` / `parts.parameter`
+ * 这类**主数据**,压根不在交付物名单里。结果:件上红带如实点着「缺品牌名称、缺封装」,
+ * 提交钮却是绿的,空手交卷一路放行。**闸看的表和件上摆的表不是同一张,闸就等于没有。**
+ *
+ * 声明的是台账地址(`域.对象`),基座据此现算缺几格 —— 与红带、放行门 L1 同一个数同一套算法。
+ * 优先级:卡上绑了表(`blocks`)> 这一项 > 必交付物声明。
+ */
+ gateLedgers: z.array(z.string()).optional(),
gate: z
.object({
/** 能交了吗 */
diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs
index 418f80f..230cc91 100644
--- a/tools/e2e-a2.mjs
+++ b/tools/e2e-a2.mjs
@@ -31,6 +31,17 @@ const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, '');
const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp';
const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? '';
const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? '';
+/**
+ * **直供 token 的旁路**(`JCAI_E2E_BEARER`)—— 手边没有服务账号客户端时的临时通道:
+ * 直接给一枚已经拿到手的 access token(比如从浏览器会话里取的、有效期调长过的)。
+ * 两处代价说在前头,别不知不觉踩上:
+ * ①**卡会落到那个人的真工作台上**(服务账号那条路才是「绝不派到用户工作台」的);
+ * ②**不会自动续期** —— 过期了整套当场 401,得换一枚重跑。
+ * **给了就用它** —— 显式设了这个变量就是要走旁路,哪怕环境里还留着一对
+ * `KEYCLOAK_E2E_CLIENT_ID/SECRET`(常见于 `.stable/env` 里的历史残留,还未必有效);
+ * 不设它才走服务账号那条正路(ADR-012)。
+ */
+const DIRECT_BEARER = (process.env.JCAI_E2E_BEARER ?? '').replace(/^Bearer\s+/i, '').trim();
/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
@@ -758,7 +769,8 @@ let bearer = '';
let bearerAt = 0;
async function api(path, init = {}) {
- if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
+ /* 直供的那枚续不了期(没有 refresh 凭据),只有服务账号那条路才换 */
+ if (bearer !== '' && DIRECT_BEARER === '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login();
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) },
@@ -779,8 +791,14 @@ async function api(path, init = {}) {
}
async function login() {
+ if (DIRECT_BEARER !== '') {
+ if (bearer === '') console.log(' 身份:JCAI_E2E_BEARER 直供的 token(不续期;卡会落到这枚 token 主人的工作台)');
+ bearer = `Bearer ${DIRECT_BEARER}`;
+ bearerAt = Date.now();
+ return;
+ }
if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') {
- throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012)');
+ throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012);临时可用 JCAI_E2E_BEARER 直供一枚');
}
const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, {
method: 'POST',