人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -139,6 +139,17 @@ export type Redlight = { readonly criterion: string; readonly statement: string
export type EnabledSet = {
readonly entries: readonly EnabledEntry[];
/**
* **进行中的项** —— 已开工、还没交出成果的那些。
*
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
*
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
*/
readonly inProgress: readonly EnabledEntry[];
/** 待裁量评估的语义性前提 */
readonly judgements: readonly Redlight[];
/** 目标缺口:未绿的判据 */
@@ -196,6 +207,16 @@ export function publishEnabled(
return {
entries,
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
inProgress: model.items
.filter((item) => byId.get(item.id)?.state === 'Active')
.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
requiresManualStart: item.manualActivation,
discretionary: item.discretionary,
})),
judgements,
gaps: unmetCriteria,
activeStages: runtime.items
@@ -219,7 +240,16 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
if (enabled.gaps.length === 0) return { kind: 'converged' };
const intersection =
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
/*
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
*/
if (intersection.length === 0) {
return enabled.inProgress.length > 0
? { kind: 'proceed', candidates: enabled.inProgress }
: { kind: 'breakpoint', gaps: enabled.gaps };
}
return { kind: 'proceed', candidates: intersection };
}
@@ -229,7 +259,12 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
if (!model.autoComplete) return false;
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
/*
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
*/
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
const allDone = required.every((i) => {
const state = byId.get(i.id)?.state;
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
@@ -255,6 +290,43 @@ export function mayTerminateCase(by: TerminalAuthority): boolean {
}
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
/**
* 前提达成即达成的里程碑 —— **不需要谁去按**。
*
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
* 让 Agent 每拍都看到一堆自己按不动的东西。
*
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
*/
export function achieveMilestones(
model: CaseModel,
runtime: CaseRuntime,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
): { runtime: CaseRuntime; achieved: string[] } {
const byId = new Map(runtime.items.map((i) => [i.id, i]));
let next = runtime;
const achieved: string[] = [];
for (const item of model.items) {
if (item.type !== 'milestone') continue;
if (byId.get(item.id)?.state !== 'Available') continue;
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
if (item.entryCriteria.length === 0) continue;
const met = item.entryCriteria.every(
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
);
if (!met) continue;
next = occur(next, item.id);
achieved.push(item.id);
}
return { runtime: next, achieved };
}
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
@@ -279,3 +351,77 @@ export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
),
};
}
/**
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
*
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
*
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
*/
export function criterionKey(raw: string): string {
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
/*
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
*
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
*
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
*/
return tight.replace(/\(l[1-5]\)$/, '');
}
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
*
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
*/
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
const names = new Set<string>();
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
}
if (names.size === 0) return '';
const readings = [...names].map((name) => {
const value = scope[name];
if (value === undefined || value === null) return `${name}=(没写过)`;
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
});
/*
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
*
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
*/
const rowKeys = [...names]
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
.map((n) => {
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
if (typeof listed !== 'string' || listed === '') return '';
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
})
.filter((s) => s !== '');
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
}
@@ -0,0 +1,50 @@
/**
* 阶段进度 —— **给人看的刻度**,纯函数、零 IO(编码规范 §5.3)。
*
* 计划项是给引擎的粒度(A1 六项);阶段是给人的粒度(三段 + 收口)。
* 两层分开,是因为它们回答的是两个问题:
* · 计划项:这一件事干完没有 —— 引擎要能判,所以拆得细;
* · 阶段:这一单到哪儿了 —— 人要能扫一眼看懂,所以合得粗。
* 拿计划项当进度条,人看到的是「6 格里过了 3 格」,而那 6 格他一个也说不出是什么。
*/
import type { CasePhase } from '@contracts';
import type { ItemRuntime } from './lifecycle.ts';
export type PhaseProgress = {
/** 第几阶段,1 起;全部阶段过完但还没收口 = 最后一格(结束态) */
readonly index: number;
/** 总格数 = 声明的阶段数 **+ 1**(结束态) */
readonly total: number;
/** 这一格叫什么 */
readonly name: string;
};
/** 结束态的名字 —— 每个活动都以放行收口(铁律③),不由活动自己起名 */
export const CLOSING_PHASE = '放行收口';
/**
* 算这一单在第几阶段。
*
* 判据是「这一阶段的计划项**全部收完**」——
* · 收完(Completed / Terminated,后者是被跳过的裁量项)算过;
* · 没收完就停在这一阶段,哪怕其中一项已经在办。
* 从前往后找第一个没过的阶段:那就是当下这一格。全过了 = 进结束态。
*
* `terminal` 为真(这一单已收口)时直接给最后一格 —— 终态就是终态,
* 不必再去数计划项:放行完成之后 T_release 才收,中间那一瞬会显示成「还差一格」。
*/
export function phaseProgress(
phases: readonly CasePhase[] | undefined,
items: readonly ItemRuntime[],
terminal: boolean,
): PhaseProgress | undefined {
if (phases === undefined || phases.length === 0) return undefined;
const total = phases.length + 1;
if (terminal) return { index: total, total, name: CLOSING_PHASE };
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
for (const [at, phase] of phases.entries()) {
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
}
return { index: total, total, name: CLOSING_PHASE };
}
File diff suppressed because it is too large Load Diff