This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts
T
xcsandClaude Opus 5 0aa2fc11b0 资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-24 10:26:45 +08:00

444 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Record<ItemState, readonly ItemState[]>> = {
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<Record<CaseState, readonly CaseState[]>> = {
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<Record<string, unknown>>) => 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<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
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<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
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<string>): 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<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,
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<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(';')}`})`;
}