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

手改是**兜 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
@@ -23,6 +23,11 @@
依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3)
被依赖: loop-engine, flow-engine, skill-engine, task, process, app(治理台 · 活动库管理)
近变(2026-07-30):`list()` **不再列出全部版本都已卸载的活动**,`uninstall` 同时置
`enabled=false`。卸载只把版本状态置 `uninstalled`(版本行留着,在途任务按启动版跑完),
但活动库照旧摆着它就是撒谎 —— 实测:被真活动包顶替的槽位卸载后仍在架上,
与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
不变量(OS 11.2 / 11.3,AM 11.3):
- **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**,
「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审
@@ -0,0 +1,271 @@
/**
* 判据谓词的名字必须在台账上 —— 安装期就挡下,别等跑到放行那一刻才炸。
*
* 这条门是拿血换来的:A2/A3/A4/A6/A7 五个活动的机检判据全都引用了
* 台账里根本不存在的名字(`candidatesConfirmed` / `unquoted` / `unpriced`
* / `missingQuoteFields` / `reasonLogged` ……)。S-FEEL 对未绑定的名字求值为假,
* 于是这些判据**永远达不了标**:Agent 把活办得再干净也过不了 submit 门,
* 连拒五拍熔断 —— MVP 整条流三轮都卡死在 A2,而清单本身语法挑不出毛病。
*/
import { describe, expect, it } from 'vitest';
import type { ActivityManifest } from '@contracts';
/* 两条审读的入参已在 domain 收窄到字段级 —— 这里直接用它,不拼假清单 */
type PredicateInput = Parameters<typeof acceptanceLevelFindings>[0];
type LedgerTable = ActivityManifest['declarations']['ledger'][number];
type Deliverable = ActivityManifest['contract']['requiredDeliverables'][number];
import { acceptanceLevelFindings } from '../internal/domain/static-audit.ts';
/** 一份最小可用清单 —— 只摆本测试关心的三处:判据 / 台账 / 案卷 */
function manifest(over: {
predicate?: string | undefined;
level?: 'L1' | 'L2' | 'L3';
fields?: string[];
caseItems?: string[];
inputProps?: string[];
}): PredicateInput {
const fields = over.fields ?? ['line_total'];
const caseItems = over.caseItems ?? [];
const inputProps = over.inputProps ?? [];
return {
contract: {
input: {
data: {
type: 'object',
properties: Object.fromEntries(inputProps.map((k) => [k, { type: 'string' }])),
},
},
acceptanceCriteria: [
{
key: 'k',
label: '测试判据',
level: over.level ?? 'L2',
...(over.predicate === undefined ? {} : { predicate: over.predicate }),
},
],
},
declarations: {
ledger: [
{
domain: 'd',
object: 'record',
kind: 'record' as const,
caseKey: 'rfq_ref',
label: '表',
fields: fields.map((name) => ({
name,
type: 'integer' as const,
primaryKey: false,
masked: false,
})),
},
],
},
assets: {
businessModel: {
cmmnXml: caseItems.map((id) => `<caseFileItem id="${id}" name="x"/>`).join(''),
},
},
};
}
const codes = (m: PredicateInput) => acceptanceLevelFindings(m).map((f) => f.code);
describe('判据谓词的名字必须绑得上', () => {
it('台账有这一列 —— 放行', () => {
expect(codes(manifest({ predicate: 'line_total > 0' }))).toEqual([]);
});
it('台账没有这个名字 —— 拒装', () => {
const found = acceptanceLevelFindings(manifest({ predicate: 'candidatesConfirmed >= 2' }));
expect(found.map((f) => f.code)).toContain('acceptance-predicate-unbound');
// 报错要指名道姓:说清是哪个名字绑不上,不然改的人还得自己猜
expect(found[0]?.message).toContain('candidatesConfirmed');
});
it('案卷项也算绑得上 —— 案卷项 id 就是任务视图里的键名', () => {
expect(codes(manifest({ predicate: 'benchmark_total > 0', caseItems: ['benchmark_total'] }))).toEqual([]);
});
it('起单入参也算绑得上', () => {
expect(codes(manifest({ predicate: 'eau > 0', inputProps: ['eau'] }))).toEqual([]);
});
it('两个名字里错一个,也要报出那一个', () => {
const found = acceptanceLevelFindings(
manifest({ predicate: 'alt_request_total = type_c_total', fields: ['alt_request_total'] }),
);
expect(found).toHaveLength(1);
expect(found[0]?.message).toContain('type_c_total');
});
it('保留字与字面量不当作台账名', () => {
expect(codes(manifest({ predicate: 'need_sourcing = true and line_total > 0', fields: ['need_sourcing', 'line_total'] }))).toEqual([]);
});
it('字符串字面量里的词不当作台账名', () => {
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
});
it('L3 不机检 —— 没谓词也不报', () => {
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
});
it('L2 没谓词照旧报等级谎报,且不误报绑定', () => {
const found = codes(manifest({ level: 'L2', predicate: undefined }));
expect(found).toEqual(['acceptance-level-misreported']);
});
});
/**
* 必交付物必须给产出地址 —— 不给的话放行门永远拒。
*
* 预检数的是「本单在这张表上写了几行」;地址不给,它退回拿 key 去任务视图里找,
* 而 key 是 `benchmark-table` 这样的短横线别名 —— 视图里永远没有这个键。
* A2/A3/A4/A6/A7 五个活动全没给,活办完了也放不了行(实测 A2 死于此)。
*/
import { deliverableAddressFindings } from '../internal/domain/static-audit.ts';
type DeliverableInput = Parameters<typeof deliverableAddressFindings>[0];
const LEDGER: LedgerTable[] = [
{ domain: 'quote_alternative', object: 'benchmark', kind: 'record' as const, caseKey: 'rfq_ref', label: '表', fields: [] },
];
function withDeliverables(items: Deliverable[]): DeliverableInput {
return { contract: { requiredDeliverables: items }, declarations: { ledger: LEDGER } };
}
describe('必交付物的产出地址', () => {
it('地址指向真实台账表 —— 放行', () => {
const found = deliverableAddressFindings(
withDeliverables([{ key: 'benchmark-table', label: '对标表', ledger: 'quote_alternative.benchmark' }]),
);
expect(found).toEqual([]);
});
it('没给地址 —— 拒装', () => {
const found = deliverableAddressFindings(withDeliverables([{ key: 'benchmark-table', label: '对标表' }]));
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
expect(found[0]?.message).toContain('对标表');
});
it('地址指向不存在的表 —— 拒装,并把声明过的表摆出来', () => {
const found = deliverableAddressFindings(
withDeliverables([{ key: 'k', label: '对标表', ledger: 'quote_alternative.nope' }]),
);
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
expect(found[0]?.message).toContain('quote_alternative.benchmark');
});
it('多件里错一件,只报那一件', () => {
const found = deliverableAddressFindings(
withDeliverables([
{ key: 'a', label: '对标表', ledger: 'quote_alternative.benchmark' },
{ key: 'b', label: '初筛清单' },
]),
);
expect(found).toHaveLength(1);
expect(found[0]?.message).toContain('初筛清单');
});
});
/**
* 台账性质要核得住 —— 这条门是拿一次真实的数据丢失换来的。
*
* A4 的报价草案本该一单一份,主键却只有 `pn`:第二单报同一个料号就 upsert 覆盖上一单。
* 跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
* 而主键带单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少。
* **作者没决定这张表是单据还是主数据,主键就写歪了** —— 而清单语法挑不出毛病。
*/
import { ledgerKindFindings } from '../internal/domain/static-audit.ts';
type LedgerInput = Parameters<typeof ledgerKindFindings>[0];
type Kind = 'record' | 'master' | 'derived';
function table(over: { kind?: Kind; caseKey?: string; pk?: string[]; cols?: string[] }): LedgerInput {
const cols = over.cols ?? ['rfq_ref', 'pn'];
const pk = over.pk ?? ['rfq_ref', 'pn'];
return {
declarations: {
ledger: [
{
domain: 'd',
object: 'o',
label: '表',
kind: over.kind ?? 'record',
...(over.caseKey === undefined ? {} : { caseKey: over.caseKey }),
fields: cols.map((name) => ({
name,
type: 'text' as const,
primaryKey: pk.includes(name),
masked: false,
})),
},
],
},
};
}
const kindCodes = (m: LedgerInput) => ledgerKindFindings(m).map((f) => f.code);
describe('台账性质', () => {
it('单据:指明单标识且它在主键里 —— 放行', () => {
expect(kindCodes(table({ kind: 'record', caseKey: 'rfq_ref' }))).toEqual([]);
});
/* 这一条就是 A4 那次数据丢失的机检形态 */
it('单据没指明单标识 —— 拒装', () => {
const found = ledgerKindFindings(table({ kind: 'record', cols: ['pn'], pk: ['pn'] }));
expect(found.map((f) => f.code)).toContain('ledger-kind-invalid');
expect(found[0]?.message).toContain('caseKey');
});
it('单标识不在主键里 —— 拒装(单标识不进主键等于没有)', () => {
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'rfq_ref', pk: ['pn'] }));
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
expect(found[0]?.message).toContain('不是主键的一部分');
});
it('单标识不在自己字段里 —— 拒装', () => {
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'nope' }));
expect(found[0]?.message).toContain('不在它自己的字段里');
});
it('主数据:按业务实体分行,不要单标识 —— 放行', () => {
expect(kindCodes(table({ kind: 'master', cols: ['customer_code'], pk: ['customer_code'] }))).toEqual([]);
});
it('主数据却带着单标识 —— 拒装:它多半其实是单据,归错了类', () => {
const found = ledgerKindFindings(table({ kind: 'master', caseKey: 'rfq_ref' }));
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
expect(found[0]?.message).toContain('归错了类');
});
it('派生:按周期分行,不要单标识 —— 放行', () => {
expect(kindCodes(table({ kind: 'derived', cols: ['period'], pk: ['period'] }))).toEqual([]);
});
it('一个主键列都没有 —— 拒装:合并与去重无从谈起', () => {
const found = ledgerKindFindings(table({ kind: 'master', cols: ['a'], pk: [] }));
expect(found[0]?.message).toContain('主键列');
});
});
/**
* 行数读数 `域_对象_rows` —— 基座数出来的那一边。
*
* 它不是活动写得出的列,却必须是**绑得上的名字**:判据的两边不能都由活动自报,
* 否则活动数错一次就两边一起错、一起「对上」(实测:条目数报 9、表里躺着 10,
* 派单数与三类条数同源同错,`2 = 2` 顺利过关)。
* 但也不能一律放行 —— 对象名手滑写错,照旧要在装的时候拒。
*/
describe('行数读数认得出,写错的照旧拒装', () => {
it('声明过的表 → `域_对象_rows` 绑得上', () => {
expect(codes(manifest({ predicate: 'd_record_rows = line_total' }))).toEqual([]);
});
it('没声明过的表 → 照旧拒装(手滑一个字母,判据就永远达不了标)', () => {
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
});
});
@@ -32,8 +32,11 @@ export type {
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
export { createRegistryService } from './internal/usecase/registry-service.ts';
export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts';
export {
acceptanceLevelFindings,
deliverableAddressFindings,
ledgerKindFindings,
auditPassed,
danglingListeners,
loopFindings,
@@ -28,6 +28,26 @@ export type ExternalChecks = {
readonly sdkCompatible: boolean;
};
/**
* 两条清单审读**只读清单的一小块** —— 参数就照那一小块声明。
* 收窄不是为了测试好写:它把「这条审读看的是什么」写进类型里,
* 传全份清单照旧过(结构子类型),而读了什么一目了然。
*/
export type AcceptanceAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
};
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
readonly assets: {
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
};
};
export type LedgerAuditInput = { readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> };
export type DeliverableAuditInput = {
readonly contract: Pick<ActivityManifest['contract'], 'requiredDeliverables'>;
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
};
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/;
@@ -62,8 +82,9 @@ export function danglingListeners(manifest: ActivityManifest): StaticAuditFindin
}
/** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */
export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAuditFinding[] {
export function acceptanceLevelFindings(manifest: AcceptanceAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
const declared = boundNames(manifest);
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
const at = `contract.acceptanceCriteria[${index}]`;
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
@@ -74,6 +95,209 @@ export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAudit
at,
});
}
/*
* **谓词里的名字必须在台账上**(2026-07-31 立)。
*
* A2 的判据写的是 `candidatesConfirmed >= 2`,而台账里从来没有这个名字 ——
* S-FEEL 拿未绑定的名字比大小求值为假,于是这条判据**永远达不了标**:
* 活办得再干净也过不了 submit 门,Agent 连拒五拍熔断,整条流卡死在 A2。
* A3/A4/A6/A7 全是同一个毛病,一个都没跑通过。
*
* 这种错光看清单是看不出来的(谓词本身语法没问题),只有跑到放行那一刻才炸,
* 而那时人已经签完字了。所以它必须在**安装期**挡下:装不上,好过装上了跑死。
*/
for (const name of predicateNames(criterion.predicate)) {
if (declared.has(name)) continue;
out.push({
code: 'acceptance-predicate-unbound',
severity: 'error',
message:
`判据「${criterion.label}」的谓词引用了 \`${name}\`,而台账与案卷里都没有这个名字 —— ` +
'S-FEEL 对未绑定的名字求值为假,这条判据将永远达不了标,放行门永远拒。' +
'把它改成活动真写得出的列(通常是汇总表里的 _total 计数列)。',
at,
});
}
}
return out;
}
/**
* 必交付物的**产出地址**必须指向真实存在的台账表(2026-07-31 立)。
*
* 预检数的是「本单在这张表上写了几行」。地址不给,它退回拿 key 去任务视图里找 ——
* 而 key 是 `benchmark-table` 这样的短横线别名,视图里永远没有这个键,
* 于是「必交付物缺一不得提交放行」**永远拒**:活办完了也放不了行,连拒五拍熔断。
* 地址写错(指向不存在的表)是同一个下场,一并挡在安装期。
*/
export function deliverableAddressFindings(manifest: DeliverableAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
const tables = new Set(manifest.declarations.ledger.map((t) => `${t.domain}.${t.object}`));
for (const [index, item] of manifest.contract.requiredDeliverables.entries()) {
const at = `contract.requiredDeliverables[${index}]`;
if (item.ledger === undefined || item.ledger.trim() === '') {
out.push({
code: 'deliverable-address-unbound',
severity: 'error',
message:
`必交付物「${item.label}」没给 \`ledger\` 产出地址 —— ` +
'预检会退回拿 key 去任务视图里找,而 key 是短横线别名、视图里没有这个键,' +
'这一件将永远算「未产出」,放行门永远拒。给它写上产出落在哪张表。',
at,
});
continue;
}
if (!tables.has(item.ledger)) {
out.push({
code: 'deliverable-address-unbound',
severity: 'error',
message:
`必交付物「${item.label}」的产出地址 \`${item.ledger}\` 不是本活动声明的台账表 —— ` +
`声明过的是:${[...tables].join('、') || '(一张都没有)'}`,
at,
});
}
}
return out;
}
/**
* 台账对象的性质要**核得住**(2026-07-31 立)。
*
* 起因是一次真实的数据丢失:A4 的报价草案 `quote_pricing.draft` 本该一单一份,
* 主键却只有 `pn` —— 第二单报同一个料号就 upsert 覆盖了上一单。
* 跑了四轮 A4,表里只剩最后一轮的 3 行;而主键带单号的 `quote_intake.record`
* 是 61 行 / 20 单,一单不少。**作者没决定这张表是单据还是主数据,主键就写歪了。**
*
* 光加一个 `kind` 字段治不了 —— 声明能填错。所以每一类各核一条:
* · `record` 必须指明单标识,且它得真在主键里(否则「一单一份」是空话);
* · `master` 不该有单标识(有了说明它其实是单据,归错类了);
* · 任何表都得有主键 —— 没主键的台账合并语义无从谈起。
*/
export function ledgerKindFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
const out: StaticAuditFinding[] = [];
for (const [index, table] of manifest.declarations.ledger.entries()) {
const at = `declarations.ledger[${index}]`;
const where = `${table.domain}.${table.object}`;
const pk = table.fields.filter((f) => f.primaryKey).map((f) => f.name);
const names = new Set(table.fields.map((f) => f.name));
if (pk.length === 0) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message: `台账 \`${where}\` 一个主键列都没有 —— 合并与去重都无从谈起,写进去的行会越堆越多且认不出重复`,
at,
});
}
if (table.kind === 'record') {
if (table.caseKey === undefined) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`单据表 \`${where}\` 没指明单标识(\`caseKey\`)—— 一单一份就成了空话:` +
'主键里没有「单」,第二单碰到同一个业务键会直接覆盖上一单(实测丢过数据)。' +
'指明哪一列是单标识,或者它其实是主数据就改 `kind: \'master\'`。',
at,
});
} else if (!names.has(table.caseKey)) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message: `单据表 \`${where}\` 的单标识 \`${table.caseKey}\` 不在它自己的字段里`,
at,
});
} else if (!pk.includes(table.caseKey)) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`单据表 \`${where}\` 的单标识 \`${table.caseKey}\` **不是主键的一部分**(现主键:${pk.join(', ')})—— ` +
'单标识不进主键,等于没有单标识:同一个业务键的第二单照样覆盖第一单。',
at,
});
}
}
if (table.kind !== 'record' && table.caseKey !== undefined) {
out.push({
code: 'ledger-kind-invalid',
severity: 'error',
message:
`\`${where}\` 标成 ${table.kind} 却带着单标识 \`${table.caseKey}\` —— ` +
'跨单的数据不该按单分行;它多半其实是单据,`kind` 归错了类。',
at,
});
}
}
return out;
}
/** S-FEEL 保留字与字面量 —— 它们不是台账名,不该被当成未绑定 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/**
* 从谓词里摘出**标识符**(粗筛,宁可漏报不可误报)。
* 只认「字母开头、由字母数字下划线点组成」的片段,去掉数字、字符串字面量与保留字;
* 带点的取根名(`a.b` 的绑定看 `a`)。
*/
function predicateNames(predicate: string | undefined): string[] {
if (predicate === undefined || predicate.trim() === '') return [];
const withoutStrings = predicate.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ');
const found = withoutStrings.match(/[A-Za-z_][A-Za-z0-9_.]*/g) ?? [];
const out = new Set<string>();
for (const raw of found) {
const root = raw.split('.')[0] ?? raw;
if (SFEEL_WORDS.has(root.toLowerCase())) continue;
out.add(root);
}
return [...out];
}
/**
* 这个活动**求值时能绑到的名字**:台账所有列名 + 案卷项 id + 起单入参的字段名。
* 任务视图正是由这三样摊平出来的,所以判据能读到的也就这三样。
*/
function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
const out = new Set<string>();
for (const table of manifest.declarations.ledger) {
for (const field of table.fields) out.add(field.name);
out.add(table.object);
/*
* **基座数出来的行数读数** `域_对象_rows` —— 活动写不了它,正因如此它才有用:
* 判据的两边不能都由活动自报。实测过一次「对账过关而账是错的」——
* 条目数报 9、表里躺着 10 行,三类条数与派单数同源同错,`2 = 2` 顺利过关。
* 它只对**本活动声明过的表**成立,所以在这儿按声明生成:
* 对象名手滑写错,照旧当场拒装。
*/
out.add(`${table.domain}_${table.object}_rows`);
/*
* 另两类基座读数,与 `_rows` 同一个道理 —— **能由数据算出来的,别让活动自报**:
* · `域_对象_missing` —— 缺必填几行(按 `fields[].required` 现数);
* · `域_对象_<列>_<值>` —— 按值现数几行(取值域由 `fields[].tally` 声明)。
* 后一类是三分类条数的落点:人在放行页改了类标,分布条、去向谓词、派单对账
* **全都跟着重算** —— 手工修改本来就是为了纠正 AI 的判断,改了却不影响结果,
* 那个兜底功能就是假的。
*/
if (table.fields.some((f) => f.required === true)) out.add(`${table.domain}_${table.object}_missing`);
if (table.fields.some((f) => f.expect === true)) out.add(`${table.domain}_${table.object}_gaps`);
for (const field of table.fields) {
for (const value of field.tally ?? []) {
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
}
}
}
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]);
}
const data = manifest.contract.input.data;
if (typeof data === 'object' && data !== null) {
const props = (data as { properties?: Record<string, unknown> }).properties;
if (props !== undefined) for (const key of Object.keys(props)) out.add(key);
}
return out;
}
@@ -93,6 +317,39 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
at: 'assets.businessModel.narrative',
});
}
/*
* 剧本与阶段引的计划项 id **必须在 CMMN 里真存在** —— 对不上就在装的时候拒。
*
* 拼错一个 id 的后果是**静默失效**,而且两处症状都不像「id 写错了」:
* · 剧本对不上 → 那一步永远拿不到自己的做法与工具,模型每拍读整本说明书;
* · 阶段对不上 → 那一格永远收不完,进度条卡在那儿不动,人以为单卡住了。
* 从 XML 里认 id 用的是属性正则:这里不解析 CMMN(解析在引擎那侧),
* 只要认得出「有没有这个 id」就够。
*/
const declared = new Set([...model.cmmnXml.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
for (const step of model.steps ?? []) {
if (!declared.has(step.item)) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `剧本引的计划项在 CMMN 里不存在:${step.item}`,
at: 'assets.businessModel.steps',
});
}
}
for (const phase of model.phases ?? []) {
for (const item of phase.items) {
if (!declared.has(item)) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `阶段「${phase.name}」引的计划项在 CMMN 里不存在:${item}`,
at: 'assets.businessModel.phases',
});
}
}
}
return out;
}
@@ -106,8 +363,18 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` });
}
for (const tool of external.missingTools) {
// 工具缺口只阻塞上架,不阻塞开发(OS 7.1 契约先行)
out.push({ code: 'tool-contract-missing', severity: 'error', message: `工具调用契约未注册:${tool}` });
/*
* 工具缺口是**警告,不是拒装**(2026-07-30 降级)。
*
* 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」——
* 声明了系统里没有,那是系统的缺口,不是活动的错误。运行时已有出路:
* 未命中即**转人代办**(§6.2,人是工具的一种实现),这一步照样做得下去。
* 从前判 error 拒装,等于要求「先把工具全实现完才允许装活动」——
* 与声明式工具正面冲突,也让契约先行成了空话。
*
* 仍然如实报出来:治理台看得见「这件活动有几件工具要靠人顶着」。
*/
out.push({ code: 'tool-contract-missing', severity: 'warn', message: `工具调用契约未注册,运行时转人代办:${tool}` });
}
for (const model of external.unboundModels) {
out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` });
@@ -130,6 +397,8 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
}
out.push(...danglingListeners(manifest));
out.push(...deliverableAddressFindings(manifest));
out.push(...ledgerKindFindings(manifest));
out.push(...acceptanceLevelFindings(manifest));
out.push(...loopFindings(manifest));
@@ -132,6 +132,15 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
}
const cards: ActivityCard[] = [];
for (const [activityId, versions] of byActivity) {
/*
* 全部版本都已卸载的活动**不在架上**。
*
* 卸载只把版本状态置为 `uninstalled`(版本行留着,在途任务按启动时版本跑完)——
* 但活动库不该照旧摆着它:实测踩过,一个被真活动包顶替的槽位卸载后仍在库里,
* 与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
* 「卸了还摆着」就是界面撒谎。
*/
if (versions.every((v) => v.state === 'uninstalled')) continue;
const manifest = await deps.store.manifest(activityId);
if (manifest === undefined) continue;
cards.push({
@@ -158,6 +167,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
for (const pkg of await deps.store.listPackages(activityId)) {
await deps.store.setState(activityId, pkg.version, 'uninstalled');
}
// 一个版本都没装了,就谈不上「可用」—— 两处状态得对得上,不然治理台会摆一个假的绿灯
await deps.store.setEnabled(activityId, false);
},
async loadEnvelope(activityId) {