M3(2/5): human-tool 与 release —— 卡协议与放行门

human-tool(L3):
- 三规矩机检:调用必附上下文(裸调用拒发)/ 回传即留痕 / 完成只出自放行通过
- 工单两模式 + 对话卡族的形态校验与回传校验;驳回必附意见;补全卡「没法填」旁路;
  决策卡自定义收尾项必附理由;已答不得再答(只增不改)
- 到达面两路(负责人 → 任务对话流;他人 → 系统消息);驳回重答 = 新卡实例、原卡闭合
- 调人预算由基座在环外强制;落地失败卡 = 执行工单的 preset

release(L4):
- 落地预检五项(入参 schema / 工具可用与授权 / 连接可达 / 目标预校验 / 数据冲突)
- 预检全过才发验收工单,卡面即已验证的动作清单;不过即打回环内,不发卡
- 放行写合并 + 幂等落地(任务 × 意向);落地收敛才发射输出口;
  受阻走落地失败卡且 emittedPorts 恒空;已成动作留存不回滚

215 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:28:49 +08:00
co-authored by Claude Opus 5
parent 146e9bbfad
commit 561a2474ae
31 changed files with 4194 additions and 0 deletions
@@ -0,0 +1,33 @@
# module: human-tool
职责: 人类工具的卡协议与回传校验:工单两模式(执行 / 验收)+ 对话卡族(决策 / 补全 / 评价)、
到达面两路、跨人卡、驳回重答、调人预算的环外强制、落地失败卡的预置形态
不负责: 卡内容由谁决定(loop 临场判定 / flow 步序声明给定,见范式引擎)、
落地预检与落地执行(见 release)、消息的存放与呈现(见 message,经 `MessageOutbox` 端口)
公开出口: index.ts
- 类型:`HumanToolService` `IssueContext` `IssuedWorkOrder` `IssuedChatCard` `ReplyOutcome`
`ArrivalSurface` `CardViolation`
- 错误:`BareCallError` `ReplyRejectedError` `HumanCallBudgetExceededError`
- 端口类型:`CardStore` `MessageOutbox` `MemoryRecorder` `HumanEventPublisher` `Clock` `IdGenerator`
- 用例工厂:`createHumanToolService(deps): HumanToolService`
- 纯函数:`checkWorkOrder` `checkChatCard` `checkWorkOrderReply` `checkChatCardReply`
`arrivalOf` `producesCompletion` `allCriteriaGreen`
- adapter:`createCardStore`;Drizzle 表:`workOrderTable` `chatCardTable`
层级: L3(编码规范 §4)
依赖: @contracts;@shared/db;port:`MessageOutbox`(app 注入 message)、
`MemoryRecorder`(app 注入 memory)、`HumanEventPublisher`(app 注入 event)
被依赖: release(落地失败卡)、loop-engine、flow-engine、task、org(停用前置检查的待答卡)、app
不变量(人类工具三规矩,OS 05):
- **调用必附上下文**:`basis` 为空即 `BareCallError` —— 无上下文的裸调用基座拒发
- **回传即留痕**:每次回传都写任务记忆并以人事件抛回任务,无静默通道
- **完成只出自放行通过**:`producesCompletion` 仅在验收工单 `verdict === 'pass'` 时为真;
判据全绿只是可提交,认账永远是人的动作
- **驳回必附意见**:驳回而无意见即 `ReplyRejectedError`
- **只增不改**:已回传的工单 / 已作答的卡不得再答;驳回重答是新卡实例,原卡闭合
- **到达面两路**:收件人 = 负责人 → 任务对话流;否则 → 系统消息(跨人卡不是责任转移)
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
活动代码无从自查自守
- **落地失败卡是执行工单的 preset**,不构成第五种卡
@@ -0,0 +1,230 @@
/** 人类工具三规矩的机检 —— domain 测试零 mock(OS 05 / AM 06) */
import { describe, expect, it } from 'vitest';
import type {
AcceptanceWorkOrder,
ChatCard,
DeliverableId,
ExecutionWorkOrder,
WorkOrderId,
WorkOrderReply,
} from '@contracts';
import {
allCriteriaGreen,
arrivalOf,
checkChatCard,
checkChatCardReply,
checkWorkOrder,
checkWorkOrderReply,
producesCompletion,
} from '../internal/domain/card-rules.ts';
const OID = 'wo-1' as WorkOrderId;
const execution = (over: Partial<ExecutionWorkOrder> = {}): ExecutionWorkOrder => ({
id: OID,
at: '2026-07-27T10:00:00.000Z',
mode: 'execution',
title: '补齐 RFQ 关键字段',
basis: [{ k: '缺口', v: 'PN / EAU / 交期' }],
ask: '请向客户追问缺失字段',
fields: [{ key: 'pn', label: 'PN', kind: 'text', required: true }],
...over,
});
const acceptance = (over: Partial<AcceptanceWorkOrder> = {}): AcceptanceWorkOrder => ({
id: OID,
at: '2026-07-27T10:00:00.000Z',
mode: 'acceptance',
title: '验收统一受理记录',
basis: [{ k: '条目数', v: '12' }],
deliverable: 'd-1' as DeliverableId,
criteria: [{ label: '八字段齐', level: 'L1', pass: true }],
fields: [],
...over,
});
describe('调用必附上下文 —— 裸调用基座拒发', () => {
it('basis 为空即拒', () => {
expect(checkWorkOrder(execution({ basis: [] })).map((v) => v.code)).toContain('bare-call');
});
it('决策卡 basis 为空即拒', () => {
const card: ChatCard = {
id: 'c1' as never,
at: '2026-07-27T10:00:00.000Z',
k: 'decision',
ask: '选一个',
basis: [],
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
],
allowCustom: false,
};
expect(checkChatCard(card).map((v) => v.code)).toContain('bare-call');
});
});
describe('工单形态机检', () => {
it('规范的执行工单通过', () => {
expect(checkWorkOrder(execution())).toEqual([]);
});
it('执行工单 = 指导书 + 空表单,表单不得为空', () => {
expect(checkWorkOrder(execution({ fields: [] })).map((v) => v.code)).toContain('fields-missing');
});
it('radio 字段缺选项即拒', () => {
const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] }));
expect(v.map((i) => i.code)).toContain('options-missing');
});
it('字段 key 重复即拒', () => {
const v = checkWorkOrder(
execution({
fields: [
{ key: 'x', label: 'X', kind: 'text' },
{ key: 'x', label: 'Y', kind: 'text' },
],
}),
);
expect(v.map((i) => i.code)).toContain('field-key-duplicate');
});
it('验收工单必须列判据', () => {
expect(checkWorkOrder(acceptance({ criteria: [] })).map((v) => v.code)).toContain('criteria-missing');
});
});
describe('到达面两路', () => {
it('负责人的卡进任务对话流', () => {
expect(arrivalOf('s1', 's1')).toBe('task-chat');
});
it('别人的卡走系统消息 —— 跨人卡不是责任转移', () => {
expect(arrivalOf('s2', 's1')).toBe('system-message');
});
});
describe('回传校验', () => {
it('必填未回填即拒', () => {
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: {} };
expect(checkWorkOrderReply(execution(), reply).map((v) => v.code)).toContain('required-missing');
});
it('必填已填即过', () => {
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: { pn: 'LM358' } };
expect(checkWorkOrderReply(execution(), reply)).toEqual([]);
});
it('驳回必附意见', () => {
const reply: WorkOrderReply = {
mode: 'acceptance',
workOrderId: OID,
verdict: 'revise',
values: {},
selectedPorts: {},
};
expect(checkWorkOrderReply(acceptance(), reply).map((v) => v.code)).toContain('comment-required');
});
it('放行通过无需意见', () => {
const reply: WorkOrderReply = {
mode: 'acceptance',
workOrderId: OID,
verdict: 'pass',
values: {},
selectedPorts: {},
};
expect(checkWorkOrderReply(acceptance(), reply)).toEqual([]);
});
it('已回传的工单不得再答 —— 只增不改', () => {
const done = acceptance({ done: { at: 'x', by: 's1' as never, result: 'pass' } });
const reply: WorkOrderReply = {
mode: 'acceptance',
workOrderId: OID,
verdict: 'pass',
values: {},
selectedPorts: {},
};
expect(checkWorkOrderReply(done, reply).map((v) => v.code)).toEqual(['already-answered']);
});
it('模式不符即拒', () => {
const reply: WorkOrderReply = { mode: 'execution', workOrderId: OID, values: { pn: 'x' } };
expect(checkWorkOrderReply(acceptance(), reply).map((v) => v.code)).toEqual(['mode-mismatch']);
});
});
describe('对话卡回传', () => {
const decision: ChatCard = {
id: 'c1' as never,
at: '2026-07-27T10:00:00.000Z',
k: 'decision',
ask: '降不降价',
basis: [{ k: '底价', v: '1.80' }],
options: [
{ value: 'hold', label: '守价' },
{ value: 'cut', label: '让价' },
],
allowCustom: false,
};
it('选项内的分支通过', () => {
expect(checkChatCardReply(decision, { k: 'decision', cardId: 'c1' as never, value: 'cut' })).toEqual([]);
});
it('选项外的分支在不允许自定义时即拒', () => {
const v = checkChatCardReply(decision, { k: 'decision', cardId: 'c1' as never, value: 'other' });
expect(v.map((i) => i.code)).toEqual(['unknown-option']);
});
it('自定义收尾项必附理由', () => {
const open = { ...decision, allowCustom: true };
expect(
checkChatCardReply(open, { k: 'decision', cardId: 'c1' as never, value: 'other' }).map((i) => i.code),
).toEqual(['note-required']);
expect(
checkChatCardReply(open, { k: 'decision', cardId: 'c1' as never, value: 'other', note: '客户口头承诺追单' }),
).toEqual([]);
});
it('补全卡两条路必走其一', () => {
const card: ChatCard = {
id: 'c2' as never,
at: '2026-07-27T10:00:00.000Z',
k: 'elicitation',
ask: '补交期',
fields: [{ key: 'lead', label: '交期' }],
};
expect(
checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: {} }).map((i) => i.code),
).toEqual(['answer-required']);
expect(checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: { lead: '8周' } })).toEqual([]);
expect(
checkChatCardReply(card, { k: 'elicitation', cardId: 'c2' as never, values: {}, reason: '客户未答复' }),
).toEqual([]);
});
});
describe('完成只出自放行通过', () => {
it('放行通过才产生完成', () => {
expect(
producesCompletion({ mode: 'acceptance', workOrderId: OID, verdict: 'pass', values: {}, selectedPorts: {} }),
).toBe(true);
});
it('驳回不产生完成 —— 环内回转,任务继续', () => {
expect(
producesCompletion({
mode: 'acceptance',
workOrderId: OID,
verdict: 'redo',
values: {},
selectedPorts: {},
comment: '重做',
}),
).toBe(false);
});
it('执行工单回传不产生完成', () => {
expect(producesCompletion({ mode: 'execution', workOrderId: OID, values: {} })).toBe(false);
});
it('判据全绿只是可提交,不等于完成', () => {
expect(allCriteriaGreen(acceptance())).toBe(true);
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* module: human-tool —— 对外类型(编码规范 §10.1)。
*
* **双层边界**(§9):范式只在引擎层;本模块是前端呈现协议的后端面 ——
* 工单两模式(执行 / 验收)+ 对话卡族(决策 / 补全 / 评价)。
* 落地失败卡是**执行工单的系统预置形态**,不构成第五种卡(OS 7.3)。
*/
import type {
AcceptanceWorkOrder,
ChatCard,
ChatCardReply,
ExecutionWorkOrder,
StaffId,
TaskId,
WorkOrder,
WorkOrderReply,
} from '@contracts';
import type { ArrivalSurface, CardViolation } from './internal/domain/card-rules.ts';
export type {
AcceptanceWorkOrder,
ArrivalSurface,
CardViolation,
ChatCard,
ChatCardReply,
ExecutionWorkOrder,
WorkOrder,
WorkOrderReply,
};
export class BareCallError extends Error {
constructor(readonly violations: readonly CardViolation[]) {
super(`基座拒发:${violations.map((v) => v.message).join('; ')}`);
this.name = 'BareCallError';
}
}
export class ReplyRejectedError extends Error {
constructor(readonly violations: readonly CardViolation[]) {
super(`回传校验未过:${violations.map((v) => v.message).join('; ')}`);
this.name = 'ReplyRejectedError';
}
}
export class HumanCallBudgetExceededError extends Error {
constructor(
readonly taskId: TaskId,
readonly used: number,
readonly cap: number,
) {
super(`调人预算触顶:${used}/${cap}(打扰上限在契约授权边界,由 Harness 强制)`);
this.name = 'HumanCallBudgetExceededError';
}
}
/** 工单草稿:id / at 由基座补齐 —— ID 生成不外泄给调用方(domain 零随机) */
export type ExecutionWorkOrderDraft = Omit<ExecutionWorkOrder, 'mode' | 'done' | 'id' | 'at'> & {
readonly id?: ExecutionWorkOrder['id'] | undefined;
readonly at?: string | undefined;
};
export type AcceptanceWorkOrderDraft = Omit<AcceptanceWorkOrder, 'mode' | 'done' | 'id' | 'at'> & {
readonly id?: AcceptanceWorkOrder['id'] | undefined;
readonly at?: string | undefined;
};
/** 发卡的公共入参 */
export type IssueContext = {
readonly taskId: TaskId;
/** 任务负责人 —— 决定到达面两路 */
readonly ownerId: StaffId;
/** 收件人;缺省即负责人 */
readonly recipientId?: StaffId;
/** 契约的调人预算(打扰上限) */
readonly humanCallCap: number;
};
export type IssuedWorkOrder = {
readonly order: WorkOrder;
readonly arrival: ArrivalSurface;
/** 到达面 = system-message 时的消息 ID(跨人卡) */
readonly messageId?: string;
};
export type IssuedChatCard = {
readonly card: ChatCard;
readonly arrival: ArrivalSurface;
readonly messageId?: string;
};
export type ReplyOutcome = {
readonly order: WorkOrder;
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
readonly producesCompletion: boolean;
/** 驳回:环内回转,任务继续 */
readonly rejection?: { readonly kind: 'redo' | 'revise'; readonly comment: string };
/** 选择组的出口选择 —— 随放行一并确认(AM 1.2) */
readonly selectedPorts: Readonly<Record<string, string>>;
};
export type HumanToolService = {
/** 发执行工单;裸调用拒发,预算触顶熔断。id / at 留空即由基座生成 */
issueExecution(context: IssueContext, order: ExecutionWorkOrderDraft): Promise<IssuedWorkOrder>;
/** 发验收工单 —— 发卡**前**必须已过落地预检(由 release 保证,见其 CONTRACT) */
issueAcceptance(context: IssueContext, order: AcceptanceWorkOrderDraft): Promise<IssuedWorkOrder>;
/** 落地失败卡:执行工单的系统预置形态,不是第五种卡 */
issueLandingFailure(
context: IssueContext,
input: { readonly failures: ReadonlyArray<{ label: string; reason: string }> },
): Promise<IssuedWorkOrder>;
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
chatCards(taskId: TaskId): Promise<ChatCard[]>;
/** org 停用前置检查要问的「待答卡」 */
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
};
+40
View File
@@ -0,0 +1,40 @@
/**
* module: human-tool —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
AcceptanceWorkOrder,
AcceptanceWorkOrderDraft,
ArrivalSurface,
CardViolation,
ChatCard,
ChatCardReply,
ExecutionWorkOrder,
ExecutionWorkOrderDraft,
HumanToolService,
IssueContext,
IssuedChatCard,
IssuedWorkOrder,
ReplyOutcome,
WorkOrder,
WorkOrderReply,
} from './contract.ts';
export { BareCallError, HumanCallBudgetExceededError, ReplyRejectedError } from './contract.ts';
export type { CardStore, Clock, HumanEventPublisher, IdGenerator, MemoryRecorder, MessageOutbox } from './port.ts';
export type { HumanToolServiceDeps } from './internal/usecase/human-tool-service.ts';
export { createHumanToolService } from './internal/usecase/human-tool-service.ts';
export {
allCriteriaGreen,
ARRIVAL_SURFACES,
arrivalOf,
checkChatCard,
checkChatCardReply,
checkWorkOrder,
checkWorkOrderReply,
producesCompletion,
} from './internal/domain/card-rules.ts';
export { createCardStore } from './internal/adapter/card-store.ts';
export { chatCardTable, workOrderTable } from './internal/adapter/human-tool-schema.ts';
@@ -0,0 +1,126 @@
/**
* human-tool 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
*/
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import type { ChatCard, StaffId, TaskId, WorkOrder } from '@contracts';
import { chatCardSchema, workOrderSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { CardStore } from '../../port.ts';
import { chatCardTable, workOrderTable } from './human-tool-schema.ts';
export function createCardStore(db: Db): CardStore {
return {
async saveWorkOrder(taskId, order, recipientId) {
await db.insert(workOrderTable).values({
id: order.id,
taskId,
recipientId,
mode: order.mode,
title: order.title,
payload: order,
at: new Date(order.at),
done: order.done ?? null,
});
},
async getWorkOrder(id) {
const rows = await db.select().from(workOrderTable).where(eq(workOrderTable.id, id)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
const order = workOrderSchema.parse({
...(row.payload as Record<string, unknown>),
...(row.done === null ? {} : { done: row.done }),
});
return { taskId: row.taskId as TaskId, order, recipientId: row.recipientId as StaffId };
},
async listWorkOrders(taskId, openOnly) {
const conditions = [eq(workOrderTable.taskId, taskId)];
if (openOnly) conditions.push(isNull(workOrderTable.done));
const rows = await db
.select()
.from(workOrderTable)
.where(and(...conditions))
.orderBy(asc(workOrderTable.at));
return rows.map((row) =>
workOrderSchema.parse({
...(row.payload as Record<string, unknown>),
...(row.done === null ? {} : { done: row.done }),
}),
);
},
async markWorkOrderDone(id, done) {
await db.update(workOrderTable).set({ done }).where(eq(workOrderTable.id, id));
},
async saveChatCard(taskId, card, recipientId) {
await db.insert(chatCardTable).values({
id: card.id,
taskId,
recipientId,
kind: card.k,
payload: card,
at: new Date(card.at),
});
},
async getChatCard(id) {
const rows = await db.select().from(chatCardTable).where(eq(chatCardTable.id, id)).limit(1);
const row = rows[0];
if (row === undefined) return undefined;
return {
taskId: row.taskId as TaskId,
card: chatCardSchema.parse(row.payload),
recipientId: row.recipientId as StaffId,
};
},
async listChatCards(taskId) {
const rows = await db
.select()
.from(chatCardTable)
.where(eq(chatCardTable.taskId, taskId))
.orderBy(asc(chatCardTable.at));
return rows.map((row) => chatCardSchema.parse(row.payload));
},
async updateChatCard(card: ChatCard) {
await db.update(chatCardTable).set({ payload: card }).where(eq(chatCardTable.id, card.id));
},
async closeChatCard(id, supersededBy, reason) {
await db.update(chatCardTable).set({ supersededBy, rejectedReason: reason }).where(eq(chatCardTable.id, id));
},
/** 调人次数 = 发出的工单 + 对话卡 —— 打扰率与预算的计数面(OS 8.3) */
async humanCallCount(taskId) {
const orders = await db
.select({ n: sql<number>`count(*)::int` })
.from(workOrderTable)
.where(eq(workOrderTable.taskId, taskId));
const cards = await db
.select({ n: sql<number>`count(*)::int` })
.from(chatCardTable)
.where(eq(chatCardTable.taskId, taskId));
return (orders[0]?.n ?? 0) + (cards[0]?.n ?? 0);
},
async openCardsOf(staffId) {
const orders = await db
.select({ id: workOrderTable.id, title: workOrderTable.title })
.from(workOrderTable)
.where(and(eq(workOrderTable.recipientId, staffId), isNull(workOrderTable.done)));
const cards = await db
.select({ id: chatCardTable.id, kind: chatCardTable.kind })
.from(chatCardTable)
.where(and(eq(chatCardTable.recipientId, staffId), isNull(chatCardTable.supersededBy)));
return [
...orders.map((o) => ({ cardId: o.id, title: o.title })),
...cards.map((c) => ({ cardId: c.id, title: `${c.kind} 卡` })),
];
},
};
}
export type { WorkOrder };
@@ -0,0 +1,38 @@
/**
* human-tool 的 sys schema 表(编码规范 §7 / ADR-002)。
*/
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
/** 工单两模式 —— 执行 / 验收;落地失败卡是执行工单的 preset,不另立表 */
export const workOrderTable = sys.table(
'work_order',
{
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
recipientId: text('recipient_id').notNull(),
mode: text('mode').notNull(),
title: text('title').notNull(),
payload: jsonb('payload').notNull(),
at: timestamp('at', { withTimezone: true }).notNull(),
done: jsonb('done'),
},
(t) => [index('work_order_task_idx').on(t.taskId, t.at), index('work_order_recipient_idx').on(t.recipientId)],
);
/** 对话卡族 —— 决策 / 补全 / 评价 */
export const chatCardTable = sys.table(
'chat_card',
{
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
recipientId: text('recipient_id').notNull(),
kind: text('kind').notNull(),
payload: jsonb('payload').notNull(),
at: timestamp('at', { withTimezone: true }).notNull(),
/** 驳回重答:原卡以 supersededBy 闭合 —— 只增不改 */
supersededBy: text('superseded_by'),
rejectedReason: text('rejected_reason'),
},
(t) => [index('chat_card_task_idx').on(t.taskId, t.at), index('chat_card_recipient_idx').on(t.recipientId)],
);
@@ -0,0 +1,176 @@
/**
* 人类工具三规矩的机检 —— 纯逻辑,零 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: '执行工单必须给出工作要求' });
if (order.fields.length === 0) {
out.push({ code: 'fields-missing', message: '执行工单 = 指导书 + 空表单,表单不得为空' });
}
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<string>();
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: '决策卡必附依据快照,基座拒发' });
if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });
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)' });
}
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 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: '该决策已选,只增不改' }];
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 === '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);
}
@@ -0,0 +1,273 @@
/**
* human-tool 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type {
AcceptanceWorkOrder,
ChatCard,
ChatCardReply,
ExecutionWorkOrder,
StaffId,
WorkOrder,
WorkOrderReply,
} from '@contracts';
import { cardIdSchema, workOrderIdSchema } from '@contracts';
import type {
HumanToolService,
IssueContext,
IssuedChatCard,
IssuedWorkOrder,
ReplyOutcome,
} from '../../contract.ts';
import { BareCallError, HumanCallBudgetExceededError, ReplyRejectedError } from '../../contract.ts';
import type { CardStore, Clock, HumanEventPublisher, IdGenerator, MemoryRecorder, MessageOutbox } from '../../port.ts';
import {
arrivalOf,
checkChatCard,
checkChatCardReply,
checkWorkOrder,
checkWorkOrderReply,
producesCompletion,
} from '../domain/card-rules.ts';
export type HumanToolServiceDeps = {
readonly cards: CardStore;
readonly outbox: MessageOutbox;
readonly memory: MemoryRecorder;
readonly events: HumanEventPublisher;
readonly clock: Clock;
readonly ids: IdGenerator;
};
export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService {
/** 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1) */
async function assertBudget(context: IssueContext): Promise<void> {
const used = await deps.cards.humanCallCount(context.taskId);
if (used >= context.humanCallCap) {
throw new HumanCallBudgetExceededError(context.taskId, used, context.humanCallCap);
}
}
async function deliver(
context: IssueContext,
kind: 'work-order' | 'chat-card',
id: string,
title: string,
body: string,
payload: Record<string, unknown>,
): Promise<{ arrival: ReturnType<typeof arrivalOf>; messageId?: string }> {
const recipient = context.recipientId ?? context.ownerId;
const arrival = arrivalOf(recipient, context.ownerId);
await deps.memory.append({
taskId: context.taskId,
kind: 'card-sent',
title,
detail: { kind, id, arrival, recipient },
});
if (arrival === 'task-chat') return { arrival };
// 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」
const message = await deps.outbox.sendElicitation({
staffId: recipient,
title,
body,
cardId: id,
sourceTaskId: context.taskId,
payload,
});
return { arrival, messageId: message.id };
}
async function issueOrder(context: IssueContext, order: WorkOrder): Promise<IssuedWorkOrder> {
const violations = checkWorkOrder(order);
if (violations.length > 0) throw new BareCallError(violations);
await assertBudget(context);
const recipient = context.recipientId ?? context.ownerId;
await deps.cards.saveWorkOrder(context.taskId, order, recipient);
const delivered = await deliver(
context,
'work-order',
order.id,
order.title,
order.mode === 'execution' ? order.ask : `请验收:${order.title}`,
{ mode: order.mode },
);
return { order, arrival: delivered.arrival, ...(delivered.messageId === undefined ? {} : { messageId: delivered.messageId }) };
}
return {
async issueExecution(context, draft) {
const order: ExecutionWorkOrder = {
...draft,
id: workOrderIdSchema.parse(draft.id ?? deps.ids.next()),
at: draft.at ?? deps.clock.now().toISOString(),
mode: 'execution',
};
return issueOrder(context, order);
},
async issueAcceptance(context, draft) {
const order: AcceptanceWorkOrder = {
...draft,
id: workOrderIdSchema.parse(draft.id ?? deps.ids.next()),
at: draft.at ?? deps.clock.now().toISOString(),
mode: 'acceptance',
};
return issueOrder(context, order);
},
/** 落地失败卡 = 执行工单的系统预置形态(作业指导 = 失败动作与原因,回填 = 处置选择) */
async issueLandingFailure(context, input) {
const order: ExecutionWorkOrder = {
id: workOrderIdSchema.parse(deps.ids.next()),
at: deps.clock.now().toISOString(),
mode: 'execution',
preset: 'landing-failure',
title: '落地受阻:请处置失败动作',
basis: input.failures.map((f) => ({ k: f.label, v: f.reason })),
ask: '以下动作在放行后执行失败。已成动作留存不回滚,请逐项处置。',
standard: '每项给出处置选择;全部处置完毕后任务方可收敛终态。',
steps: input.failures.map((f) => `${f.label}:${f.reason}`),
fields: input.failures.map((f, index) => ({
key: `disposition_${index}`,
label: f.label,
kind: 'radio' as const,
required: true,
options: [
{ value: 'retry', label: '重试' },
{ value: 'offline', label: '线下完成后回填' },
{ value: 'abandon', label: '放弃非必交付项' },
{ value: 'halt', label: '叫停', negative: true },
],
})),
submitLabel: '提交处置',
};
return issueOrder(context, order);
},
async issueChatCard(context, card) {
const violations = checkChatCard(card);
if (violations.length > 0) throw new BareCallError(violations);
await assertBudget(context);
const recipient = context.recipientId ?? context.ownerId;
await deps.cards.saveChatCard(context.taskId, card, recipient);
const delivered = await deliver(context, 'chat-card', card.id, card.ask, card.ask, { k: card.k });
return { card, arrival: delivered.arrival, ...(delivered.messageId === undefined ? {} : { messageId: delivered.messageId }) };
},
async replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome> {
const found = await deps.cards.getWorkOrder(reply.workOrderId);
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '工单不存在' }]);
const violations = checkWorkOrderReply(found.order, reply);
if (violations.length > 0) throw new ReplyRejectedError(violations);
const at = deps.clock.now().toISOString();
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
// 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆
await deps.memory.append({
taskId: found.taskId,
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
title: reply.mode === 'acceptance' ? `放行裁决:${reply.verdict}` : `执行工单回传:${found.order.title}`,
detail: { by, values, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
});
// 回传经统一入口以人事件抛回任务
await deps.events.publishHumanEvent({
taskId: found.taskId,
cardId: reply.workOrderId,
payload: { ...reply },
});
const updated = await deps.cards.getWorkOrder(reply.workOrderId);
const order = updated?.order ?? found.order;
return {
order,
producesCompletion: producesCompletion(reply),
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
: {}),
selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {},
};
},
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard> {
const found = await deps.cards.getChatCard(reply.cardId);
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
const violations = checkChatCardReply(found.card, reply);
if (violations.length > 0) throw new ReplyRejectedError(violations);
const at = deps.clock.now().toISOString();
const updated = applyChatReply(found.card, reply, by, at);
await deps.cards.updateChatCard(updated);
await deps.memory.append({
taskId: found.taskId,
kind: 'card-reply',
title: `${found.card.k} 卡回传`,
detail: { by, ...reply },
});
await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } });
return updated;
},
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
async rejectAndReask(cardId, reason) {
const found = await deps.cards.getChatCard(cardId);
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
const fresh: ChatCard = {
...found.card,
id: cardIdSchema.parse(deps.ids.next()),
at: deps.clock.now().toISOString(),
ask: `${found.card.ask}\n【驳回重答】${reason}`,
...(found.card.k === 'decision' ? { chosen: undefined } : { done: undefined }),
} as ChatCard;
await deps.cards.saveChatCard(found.taskId, fresh, found.recipientId);
await deps.cards.closeChatCard(cardId, fresh.id, reason);
await deps.memory.append({
taskId: found.taskId,
kind: 'card-sent',
title: `驳回重答:${reason}`,
detail: { original: cardId, reask: fresh.id },
});
return { card: fresh, arrival: arrivalOf(found.recipientId, found.recipientId) } satisfies IssuedChatCard;
},
async openWorkOrders(taskId) {
return deps.cards.listWorkOrders(taskId, true);
},
async chatCards(taskId) {
return deps.cards.listChatCards(taskId);
},
async openCardsOf(staffId) {
return deps.cards.openCardsOf(staffId);
},
};
}
function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: string): ChatCard {
if (card.k === 'decision' && reply.k === 'decision') {
return {
...card,
chosen: { value: reply.value, at, by, ...(reply.note === undefined ? {} : { note: reply.note }) },
};
}
if (card.k === 'elicitation' && reply.k === 'elicitation') {
return {
...card,
done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) },
};
}
if (card.k === 'rating' && reply.k === 'rating') {
return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } };
}
return card;
}
@@ -0,0 +1,7 @@
{
"name": "@modules/human-tool",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+55
View File
@@ -0,0 +1,55 @@
/**
* module: human-tool —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*/
import type { ChatCard, StaffId, TaskId, WorkOrder } from '@contracts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type CardStore = {
saveWorkOrder(taskId: TaskId, order: WorkOrder, recipientId: StaffId): Promise<void>;
getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>;
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
updateChatCard(card: ChatCard): Promise<void>;
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
/** 调人次数 —— 打扰率与预算的计数面 */
humanCallCount(taskId: TaskId): Promise<number>;
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
};
/** 跨人卡的到达面 —— app 注入 `message`(L2) */
export type MessageOutbox = {
sendElicitation(input: {
staffId: StaffId;
title: string;
body: string;
cardId: string;
sourceTaskId: string;
payload: Record<string, unknown>;
}): Promise<{ id: string }>;
};
/** 回传即留痕 —— app 注入 `memory`(L2) */
export type MemoryRecorder = {
append(input: {
taskId: TaskId;
kind: 'card-sent' | 'card-reply' | 'release-verdict';
title: string;
detail?: Record<string, unknown>;
}): Promise<void>;
};
/** 人事件 —— app 注入 `event`(L1):回传经统一入口写回任务 */
export type HumanEventPublisher = {
publishHumanEvent(input: {
taskId: TaskId;
cardId: string;
payload: Record<string, unknown>;
}): Promise<void>;
};