/** * CMMN 标准生命周期与双向驱动 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 04)。 * * 「状态的权威是 CMMN 规范的标准生命周期:**活动不定义状态,模型不发明状态**; * 引擎按规范推进,守卫(时限 / 预算 / 预授权 / 监听)仅可参数化。」 */ import type { CaseModel, Criterion, PlanItem } from './cmmn-parse.ts'; /* ── 两副状态表(AM 4.1) ────────────────────────────────────────────── */ /** 案计划(整案)五态 */ export const CASE_STATES = ['Active', 'Completed', 'Suspended', 'Terminated', 'Failed'] as const; export type CaseState = (typeof CASE_STATES)[number]; /** 计划项八态 */ export const ITEM_STATES = [ 'Available', 'Enabled', 'Disabled', 'Active', 'Suspended', 'Failed', 'Completed', 'Terminated', ] as const; export type ItemState = (typeof ITEM_STATES)[number]; /** 迁移拓扑 —— 引擎不得私改 */ const ITEM_TRANSITIONS: Readonly> = { Available: ['Enabled', 'Active', 'Terminated', 'Suspended'], Enabled: ['Active', 'Disabled', 'Terminated', 'Suspended'], Disabled: ['Enabled', 'Terminated'], Active: ['Completed', 'Failed', 'Suspended', 'Terminated'], Suspended: ['Available', 'Enabled', 'Active', 'Terminated'], Failed: ['Active', 'Terminated'], Completed: [], Terminated: [], }; const CASE_TRANSITIONS: Readonly> = { Active: ['Completed', 'Suspended', 'Terminated', 'Failed'], Suspended: ['Active', 'Terminated'], Failed: ['Active', 'Terminated'], Completed: [], Terminated: [], }; export class IllegalTransitionError extends Error { constructor( readonly from: string, readonly to: string, ) { super(`生命周期迁移非法:${from} → ${to}(语义忠实:引擎不得私改迁移拓扑)`); this.name = 'IllegalTransitionError'; } } export function canTransitionItem(from: ItemState, to: ItemState): boolean { return ITEM_TRANSITIONS[from].includes(to); } export function assertItemTransition(from: ItemState, to: ItemState): void { if (!canTransitionItem(from, to)) throw new IllegalTransitionError(from, to); } export function canTransitionCase(from: CaseState, to: CaseState): boolean { return CASE_TRANSITIONS[from].includes(to); } /* ── 运行时快照 ─────────────────────────────────────────────────────── */ export type ItemRuntime = { readonly id: string; readonly state: ItemState; /** repetitionRule 的实例序号 */ readonly instance: number; /** 语义性前提待裁量评估的标记 */ readonly awaitingJudgement?: boolean; /** * **这一实例期间,AI 有没有真的触达过世界**(human / entry / decide / listen 之一)。 * * 只给「触达世界」的项(`manualActivation` = true)当门槛用:那类项完成与否 * 天生**不该由数据形状推出来**(AM 12.2 的原理一),唯一的证据是「真发过一张卡 * 或真立过一次监听」。没有这个字段之前,`finish` 只看生命周期迁移合不合法 —— * 模型可以在从没给人发过卡的情况下直接宣告某个人工激活项「完成」, * 人的那一步就此被悄悄跳过(2026-08-21 用户实测:E04 S5 执行回执从未派单, * 覆盖度收口直接用了空回执重算,人根本没被问过)。 */ readonly worldTouched?: boolean; }; export type CaseRuntime = { readonly state: CaseState; readonly items: readonly ItemRuntime[]; /** 已达成的里程碑 */ readonly milestones: readonly string[]; }; export function initialRuntime(model: CaseModel): CaseRuntime { return { state: 'Active', items: model.items.map((item) => ({ id: item.id, // 裁量项不随阶段自动入场:从 planning table 由 Agent 临场规划 state: item.discretionary ? 'Available' : 'Available', instance: 1, })), milestones: [], }; } /* ── 前提求值 ───────────────────────────────────────────────────────── */ export type ConditionEvaluator = (expression: string, scope: Readonly>) => boolean; export type CriterionVerdict = | { readonly kind: 'satisfied' } | { readonly kind: 'unsatisfied' } /** 语义性前提不可机算 —— 标「待裁量评估」,交上层 Agent,引擎**不代裁量** */ | { readonly kind: 'needs-judgement'; readonly statement: string }; export function evaluateCriterion( criterion: Criterion, scope: Readonly>, changedRefs: ReadonlySet, evaluate: ConditionEvaluator, ): CriterionVerdict { // onPart:监听的数据项写入 / 计划项迁移发生过才算「件到」 if (criterion.onParts.length > 0) { const allArrived = criterion.onParts.every((part) => changedRefs.has(part.ref)); if (!allArrived) return { kind: 'unsatisfied' }; } if (criterion.ifPart === undefined) return { kind: 'satisfied' }; if (criterion.semantic) return { kind: 'needs-judgement', statement: criterion.ifPart }; return evaluate(criterion.ifPart, scope) ? { kind: 'satisfied' } : { kind: 'unsatisfied' }; } /* ── Enabled 集发布:引擎推进的终点,Agent 裁量的起点 ────────────────── */ export type EnabledEntry = { readonly id: string; readonly name: string; readonly type: PlanItem['type']; /** manualActivationRule = true:进入 Active 永远出自 Agent 的裁量启动 */ readonly requiresManualStart: boolean; readonly discretionary: boolean; }; 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[]; /** 目标缺口:未绿的判据 */ readonly gaps: readonly string[]; /** 阶段位置:Active 的阶段 */ readonly activeStages: readonly string[]; }; /** * 前提前推(开展):任务视图变化 → 前提满足 → 新计划项 Enabled。 * 「**Enabled ≠ Active**」—— 引擎按规范推进到 Enabled 为止; * 凡触达世界或产出关键交付的计划项必须 manualActivationRule = true。 */ export function publishEnabled( model: CaseModel, runtime: CaseRuntime, scope: Readonly>, changedRefs: ReadonlySet, evaluate: ConditionEvaluator, unmetCriteria: readonly string[] = [], ): EnabledSet { const byId = new Map(runtime.items.map((i) => [i.id, i])); const entries: EnabledEntry[] = []; const judgements: Redlight[] = []; for (const item of model.items) { const state = byId.get(item.id)?.state ?? 'Available'; if (state !== 'Available' && state !== 'Enabled') continue; // 父阶段未激活的项不入场 if (item.parent !== undefined && byId.get(item.parent)?.state !== 'Active') continue; let satisfied = true; for (const criterion of item.entryCriteria) { const verdict = evaluateCriterion(criterion, scope, changedRefs, evaluate); if (verdict.kind === 'needs-judgement') { judgements.push({ criterion: criterion.id, statement: verdict.statement }); satisfied = false; break; } if (verdict.kind === 'unsatisfied') { satisfied = false; break; } } if (!satisfied) continue; entries.push({ id: item.id, name: item.name, type: item.type, requiresManualStart: item.manualActivation, discretionary: item.discretionary, }); } 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 .filter((i) => i.state === 'Active' && model.items.find((m) => m.id === i.id)?.type === 'stage') .map((i) => i.id), }; } /** * 判据后拉(完成):目标缺口 → 由什么推出 → 落到一个 Enabled 的计划项。 * 「导航即交集,断点即人环:下一步 = 当下可为(Enabled 集)∩ 目标所需; * **交集为空即断点** —— 调人或立监听的判定时机。」 */ export type Navigation = | { readonly kind: 'proceed'; readonly candidates: readonly EnabledEntry[] } /** 断点:目标所需的缺口无法由任何 Enabled 项补足 */ | { readonly kind: 'breakpoint'; readonly gaps: readonly string[] } | { readonly kind: 'converged' }; export function navigate(enabled: EnabledSet, required: ReadonlySet): Navigation { if (enabled.gaps.length === 0) return { kind: 'converged' }; const intersection = required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id)); /* * 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。 * 从前只看 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 }; } /* ── 自动完成与终局 ─────────────────────────────────────────────────── */ /** 「齐了即收」(AutoComplete):必做项完成且无进行中项即结案 */ export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean { if (!model.autoComplete) return false; const byId = new Map(runtime.items.map((i) => [i.id, i])); /* * 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。 * 从前只排除 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'; }); const noneRunning = runtime.items.every((i) => i.state !== 'Active'); return allDone && noneRunning; } /** * 终局唯放行与 Harness(必须): * 案计划 Completed **必经放行卡**;Terminated 只出自 Harness 与负责人; * **AI 不得产生终局迁移**。 */ export const TERMINAL_AUTHORITIES = ['release', 'harness', 'owner', 'governance'] as const; export type TerminalAuthority = (typeof TERMINAL_AUTHORITIES)[number]; export function mayCompleteCase(by: TerminalAuthority): boolean { return by === 'release'; } export function mayTerminateCase(by: TerminalAuthority): boolean { return by === 'harness' || by === 'owner' || by === 'governance'; } /** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */ /** * 前提达成即达成的里程碑 —— **不需要谁去按**。 * * CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。 * 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西 * 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available: * 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里, * 让 Agent 每拍都看到一堆自己按不动的东西。 * * 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。 */ export function achieveMilestones( model: CaseModel, runtime: CaseRuntime, scope: Readonly>, changedRefs: ReadonlySet, 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, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: 'Completed' } : i)), milestones: runtime.milestones.includes(itemId) ? runtime.milestones : [...runtime.milestones, itemId], }; } export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemState): CaseRuntime { const current = runtime.items.find((i) => i.id === itemId); if (current === undefined) throw new IllegalTransitionError('(不存在)', to); assertItemTransition(current.state, to); return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) }; } /** human / entry / decide / listen 真发生过一次 —— 见 `ItemRuntime.worldTouched` 的注释 */ export function markWorldTouched(runtime: CaseRuntime, itemId: string): CaseRuntime { return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, worldTouched: true } : i)) }; } /** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */ export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime { return { ...runtime, items: runtime.items.map((i) => i.id === itemId ? { id: i.id, state: 'Available', instance: i.instance + 1 } : i, ), }; } /** * 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。 * * `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>): string { const names = new Set(); 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(';')}`})`; }