/** * 人类工具三规矩的机检 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 05,AM 06)。 * * 调用必附上下文(必须)—— 每张卡自带依据与快照;**无上下文的裸调用,基座必须拒发** * 回传即留痕(必须)—— 谁、何时、答了什么、依据什么,随卡入任务记忆 * 完成只出自放行(必须)—— 完成只能由验收工单的「放行通过」产生 */ import type { AcceptanceWorkOrder, ChatCard, ChatCardReply, ExecutionWorkOrder, FormField, WorkOrder, WorkOrderReply, } from '@contracts'; import { elicitationReplyIsEmpty, rejectionNeedsComment } from '@contracts'; export type CardViolation = { readonly code: string; readonly message: string }; /** 到达面两路(OS 2.1):负责人的卡进任务对话流;其他人的卡以系统消息送达 */ export const ARRIVAL_SURFACES = ['task-chat', 'system-message'] as const; export type ArrivalSurface = (typeof ARRIVAL_SURFACES)[number]; export function arrivalOf(recipientId: string, ownerId: string): ArrivalSurface { return recipientId === ownerId ? 'task-chat' : 'system-message'; } /* ── 发卡前的机检:裸调用必须被拒发 ─────────────────────────────────── */ export function checkWorkOrder(order: WorkOrder): CardViolation[] { const out: CardViolation[] = []; if (order.basis.length === 0) { out.push({ code: 'bare-call', message: '调用必附上下文:卡面无依据快照,基座拒发(OS 05)' }); } if (order.title.trim() === '') out.push({ code: 'title-missing', message: '工单标题不得为空' }); if (order.mode === 'execution') out.push(...checkExecution(order)); else out.push(...checkAcceptance(order)); return out; } function checkExecution(order: ExecutionWorkOrder): CardViolation[] { const out: CardViolation[] = []; if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' }); /* * **执行卡零输入**(2026-08-02 用户定:「执行卡片不要有任何填写的了,填写全去 * 工作区」)—— 空表单是常态:填报在工作区的件上(editGate + 孪生件), * 卡上只剩指导书、依据与提交钮。从前这里拒发空表单(「执行工单 = 指导书 + * 空表单,表单不得为空」),那一版把表单当交付通道;通道换到件上之后, * 这道拒发就成了活动照新规矩发卡、基座按旧规矩连拒到熔断(实测 A1 五拍)。 * 带字段的卡(如熔断卡的 decision)照旧合法 —— 只是不再强制。 */ out.push(...checkFieldKeys(order.fields)); return out; } function checkAcceptance(order: AcceptanceWorkOrder): CardViolation[] { const out: CardViolation[] = []; if (order.criteria.length === 0) { out.push({ code: 'criteria-missing', message: '验收工单必须逐条列出判据(成果清单的来源)' }); } // 验收等级不得谎报:L4 生成者不得自评 —— 这里只机检「等级必须标注」 for (const c of order.criteria) { if (c.label.trim() === '') out.push({ code: 'criterion-unlabeled', message: '判据必须有可读的表述' }); } out.push(...checkFieldKeys(order.fields)); return out; } function checkFieldKeys(fields: readonly FormField[]): CardViolation[] { const seen = new Set(); const out: CardViolation[] = []; for (const field of fields) { if (field.key.trim() === '') out.push({ code: 'field-key-empty', message: '表单字段 key 不得为空' }); if (seen.has(field.key)) out.push({ code: 'field-key-duplicate', message: `表单字段 key 重复:${field.key}` }); seen.add(field.key); if (field.kind === 'radio' || field.kind === 'select') { if (field.options === undefined || field.options.length === 0) { out.push({ code: 'options-missing', message: `${field.kind} 字段 ${field.key} 缺选项` }); } } } return out; } export function checkChatCard(card: ChatCard): CardViolation[] { const out: CardViolation[] = []; switch (card.k) { case 'decision': if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' }); /* * **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 —— * 改全在件上就地做,卡上只有「确认通过」一个动作。 * 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。 */ { const floor = card.variant === 'review' ? 1 : 2; if (card.options.length < floor) { out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' }); } } break; case 'elicitation': if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' }); break; case 'rating': if (card.ask.trim() === '') out.push({ code: 'ask-missing', message: '评价卡必须给出评价对象' }); break; } return out; } /* ── 回传校验:与前端提交校验同 schema(§9.2) ─────────────────────── */ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): CardViolation[] { const out: CardViolation[] = []; if (order.id !== reply.workOrderId) { out.push({ code: 'mismatch', message: '回传的工单 ID 与卡不符' }); return out; } if (order.done !== undefined) { out.push({ code: 'already-answered', message: '该工单已回传,只增不改' }); return out; } if (order.mode !== reply.mode) { out.push({ code: 'mode-mismatch', message: '回传模式与工单模式不符' }); return out; } if (reply.mode === 'acceptance') { // 驳回必附意见 —— 三规矩之一 if (rejectionNeedsComment(reply)) { out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' }); } /* * **打不回的卡就是打不回**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有 * 重做的选项,因为这里都没有接入 ai」)。卡面把那颗钮收起来只是给人看的,**这里才是门** —— * 直调 API 把「重做」交回来,单子会被推进死胡同(没有模型读那句意见,配方只会原样重提, * 撞在「打回之后台账没动过」那道闸上)。拒回把真路指出来:别签、去终止重开。 */ if (reply.verdict !== 'pass' && (order as AcceptanceWorkOrder).redoable === false) { out.push({ code: 'redo-unavailable', message: '这一步打不回:它整个由配方跑完,没有模型会读你的意见,件也只读 —— 有异议就别确认,去终止这一单重开。', }); } /* * **恰选一口,随放行一并确认**(AM 1.2)—— 与「驳回必附意见」同族的收单规矩。 * 工单投影自己声明了选择组(portChoice),放行回传就必须带组内恰一口: * 选的这一口是这次运行的分支结论,下游网关判的正是它,不能靠默认值溜过去。 * 只在**通过**时强制 —— 驳回是打回重做,没有分支结论可言。 * 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑, * 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。 */ /* * **人工验收清单要点全才能通过**(2026-08-27 用户定):卡上那几条是人核这份工单要走的步骤, * 按钮灰掉只是给人看的,这里才是门 —— 直调 API 把「通过」交回来也得把清单点全。 */ if (reply.verdict === 'pass') { const missing = checklistMissing((order as AcceptanceWorkOrder).checklist, reply.checked); if (missing.length > 0) { out.push({ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` }); } } const choice = (order as AcceptanceWorkOrder).portChoice; if (choice !== undefined && reply.verdict === 'pass') { const picked = reply.selectedPorts[choice.group]; if (picked === undefined) { out.push({ code: 'port-required', message: `放行前先选「${choice.group}」—— 选的这一口就是这单的去向` }); } else if (!choice.ports.some((p) => p.name === picked)) { out.push({ code: 'port-invalid', message: `「${picked}」不在「${choice.group}」的可选出口里` }); } } return out; } const execution = order as ExecutionWorkOrder; for (const field of execution.fields) { if (field.required !== true) continue; const value = reply.values[field.key]; if (value === undefined || value.trim() === '') { out.push({ code: 'required-missing', message: `必填字段未回填:${field.label || field.key}` }); } } return out; } /** 清单里还没点成「达标」的那几条;没有清单就是空 */ export function checklistMissing(checklist: ReadonlyArray | undefined, checked: ReadonlyArray | undefined): string[] { const done = new Set(checked ?? []); return (checklist ?? []).filter((item) => !done.has(item)); } /** * 审核卡上哪一个选项是「通过」—— 与前端 `review-face.ts` 同一口径:值叫 `approve` 的,否则推荐的那个,否则第一个。 * 清单只拦这一个选项:驳回不需要把清单点全。 */ export function reviewApproveValue(card: Extract): string | undefined { return (card.options.find((o) => o.value === 'approve') ?? card.options.find((o) => o.recommended === true) ?? card.options[0])?.value; } export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] { const out: CardViolation[] = []; if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }]; if (card.k !== reply.k) return [{ code: 'mode-mismatch', message: '回传类型与卡类型不符' }]; if (card.k === 'decision' && reply.k === 'decision') { if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }]; // 审核卡穿验收工单的衣服:选「通过」也得把人工验收清单点全(与验收工单同一道门) if (reply.value === reviewApproveValue(card)) { const missing = checklistMissing(card.checklist, reply.checked); if (missing.length > 0) return [{ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` }]; } const known = card.options.some((o) => o.value === reply.value); // 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由) if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` }); if (!known && card.allowCustom && (reply.note ?? '').trim() === '') { out.push({ code: 'note-required', message: '自定义收尾项必附理由' }); } } /* * 数据录入卡 —— **确认那一下就是写入**,所以门要在这儿把住: * 必填空着不让确认(缺一格这张档就是残的,而它是主数据); * 已确认不得再改(只增不改:要改就再发一张,改动自己也是一条留痕)。 */ if (card.k === 'entry' && reply.k === 'entry') { if (card.done !== undefined) return [{ code: 'already-answered', message: '该录入已确认,只增不改' }]; /* * **一张卡可以有好几段**(公司档 + 联系人档):值按 `段号.列名` 隔开 —— * 两张表都有 `name` 这一列,平铺就会互相盖掉。 * 老形状(平键)照旧认,只喂主段:一张卡只有一段时它仍旧管用。 */ const groups = [{ fields: card.fields }, ...(card.also ?? [])]; const blank = groups.flatMap((g, at) => g.fields .filter((f) => f.required && (reply.values[`${at}.${f.key}`] ?? (at === 0 ? reply.values[f.key] : undefined) ?? '').trim() === '') .map((f) => f.label), ); if (blank.length > 0) { out.push({ code: 'required-blank', message: `必填还空着:${blank.join('、')}` }); } } if (card.k === 'elicitation' && reply.k === 'elicitation') { if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }]; // 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一 if (elicitationReplyIsEmpty(reply)) { out.push({ code: 'answer-required', message: '要么回填字段,要么说明「没法填」的理由' }); } } if (card.k === 'rating' && reply.k === 'rating') { if (card.done !== undefined) return [{ code: 'already-answered', message: '该评价已提交,只增不改' }]; } return out; } /** * 完成只出自放行通过 —— 唯一判据。 * 判据全绿只是**可提交**,认账永远是人的动作(AM 06)。 */ export function producesCompletion(reply: WorkOrderReply): boolean { return reply.mode === 'acceptance' && reply.verdict === 'pass'; } /** 判据全绿?—— L1/L2 由引擎预检,L3–L5 按分级执行;这里只看核对结果 */ export function allCriteriaGreen(order: AcceptanceWorkOrder): boolean { return order.criteria.every((c) => c.pass); }