合并 feat/act-work-05:第三个活动 ACT-WORK-05 差异评估(part-substitution-assessment@1.3.6)+ 脚本 e2e 逼出的平台侧改动

主支这边(d133d6c,PR #1/#2)是活动模块 L2/L3 前端(实例详情页、启动参数抽屉、禁用规则重定、
二次确认气泡、沙箱占位页)、工作台撤顶栏、姓名展示改姓在前(token claims 与名册同步);
分支这边 21 条:差异评估活动包(九个计划项、4 域 7 张主数据表、五张目标件平移的模版、
两枚纯函数工具、五案例 e2e 五例全 released 被拒 0)与平台侧 gateLedgers / answeredCardFact /
emptyGate / 早收只记观察 / 工具入参 null 归一 / 拒回不摆 S-FEEL 函数名,CLAUDE.md 快照跟上。

两边改的文件零重叠,自动合并无冲突。合并树上补三笔本该随各自提交入库的东西:
① api-report 两份快照 —— `org/port.d.ts`(主支加 given_name / family_name 时没刷)与
  `api/workbench.d.ts`(分支加 gateLedgers 时刷出来的文件没一起提交);
② pnpm-lock 补 `activities/part-substitution-assessment` 的 importer 条目(新 workspace 包,
  与当年 mail 模块同一回事;`--frozen-lockfile --offline` 装过核对一致);
③ org CONTRACT.md 补一笔 `VerifiedClaims` 增两个可选散件的影响半径。

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 11:37:29 +08:00
co-authored by Claude Fable 5
36 changed files with 5355 additions and 63 deletions
@@ -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: '这一问' });
@@ -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']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
@@ -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<Record<string, unknown>>): 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<string, unknown> | 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<string, unknown> | 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;
+5
View File
@@ -59,3 +59,8 @@
`RosterSyncOutcome` 增 `failed`(契约变更,api-report 已刷新,`docs/API文档-鉴权与人员.md` §3/§7 同步);
`AccessLockoutError` / `assertNotLockedOut` 收可选 `message`(两个来路各说各的话)。
下游 depcruise 反查:`rosterSyncOutcomeSchema` 只被 `syncStaffRoute.output` 与本模块用,web 尚无消费方。
影响半径(2026-08-26,姓在前;2026-08-27 合并 feat/act-work-05 时补记):`VerifiedClaims` 增可选 `given_name` / `family_name`
(Keycloak 拼好的 `name` 是「名 姓」西式全名,展示「姓 名」得拿散件自己拼;两个散件都缺才回退 `name`),
`identityOf` 与 `createKeycloakRoster`(`lastName firstName`)同一把尺。
加的是可选列,非破坏性;api-report `org/port.d.ts` 随合并刷新(主支那条提交没刷)。