@contracts 首版(§8.1 九个文件): - id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件 - card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿) - contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类 - manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约 模块(L1): - event 信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定 - ledger schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查 - org 岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种 - llm 四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一 app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封 ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密 (ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误) 门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
493 lines
17 KiB
TypeScript
493 lines
17 KiB
TypeScript
/**
|
|
* 人类工具的卡协议(编码规范 §8.1 / §9;语义出处 OS 05,AM 06/07)。
|
|
*
|
|
* **双层边界**:范式(loop / flow / skill)是后端实现逻辑的模型,只存在于引擎层;
|
|
* 本文件是**前端呈现协议**,前端永远感知不到范式。旧文「四卡」的映射:
|
|
* 执行卡 → 执行工单(work order · execution)
|
|
* 放行卡 → 验收(工作区可修订报告 + 对话区验收单)(work order · acceptance)
|
|
* 决策 / 补全 → 对话卡(decision / elicitation)
|
|
* 落地失败卡 = 执行工单的系统预置形态,不构成第五种卡(OS 7.3)。
|
|
*
|
|
* 形状以 2026-07-26 原型定稿为准(`~/coding/JCAI_PRD/workbench/_data/model.ts`);
|
|
* 枚举值改用英文标识符 + 本文件的 *_LABEL 字典,界面文案由字典给出、与原型逐字一致。
|
|
*/
|
|
import { z } from 'zod';
|
|
import {
|
|
cardIdSchema,
|
|
deliverableIdSchema,
|
|
stageIdSchema,
|
|
staffIdSchema,
|
|
workOrderIdSchema,
|
|
} from './id.ts';
|
|
import { acceptanceLevelSchema, rejectionKindSchema } from './terminal.ts';
|
|
|
|
/* ── 基础原语 ─────────────────────────────────────────────────────────── */
|
|
|
|
export const TONES = ['ok', 'warn', 'danger', 'brand', 'muted'] as const;
|
|
export const toneSchema = z.enum(TONES);
|
|
export type Tone = z.infer<typeof toneSchema>;
|
|
|
|
/** 依据快照:调用必附上下文(OS 05)—— 裸调用基座必须拒发 */
|
|
export const basisSchema = z.array(z.object({ k: z.string(), v: z.string() }));
|
|
export type Basis = z.infer<typeof basisSchema>;
|
|
|
|
export const formOptionSchema = z.object({
|
|
value: z.string(),
|
|
label: z.string(),
|
|
negative: z.boolean().optional(),
|
|
});
|
|
export type FormOption = z.infer<typeof formOptionSchema>;
|
|
|
|
export const FORM_FIELD_KINDS = [
|
|
'text',
|
|
'textarea',
|
|
'radio',
|
|
'number',
|
|
'select',
|
|
'check',
|
|
'date',
|
|
'money',
|
|
'file',
|
|
] as const;
|
|
export const formFieldKindSchema = z.enum(FORM_FIELD_KINDS);
|
|
export type FormFieldKind = z.infer<typeof formFieldKindSchema>;
|
|
|
|
/** 表单字段:结构化交互的原子。卡片表单由本 schema 驱动渲染与校验,后端回传校验同 schema(§9.2) */
|
|
export const formFieldSchema = z.object({
|
|
key: z.string().min(1),
|
|
label: z.string(),
|
|
kind: formFieldKindSchema,
|
|
placeholder: z.string().optional(),
|
|
options: z.array(formOptionSchema).optional(),
|
|
/** 字段分组(渲染为表单小节) */
|
|
group: z.string().optional(),
|
|
hint: z.string().optional(),
|
|
/** AI 拟值 —— 验收工单落地时已填好,人可改全部字段 */
|
|
ai: z.string().optional(),
|
|
unit: z.string().optional(),
|
|
required: z.boolean().optional(),
|
|
cols: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
});
|
|
export type FormField = z.infer<typeof formFieldSchema>;
|
|
|
|
/* ── 视图块:视图区的结构化呈现(活动推送,只读) ────────────────────── */
|
|
|
|
const fileDocSchema = z.discriminatedUnion('kind', [
|
|
z.object({ kind: z.literal('markdown'), name: z.string(), md: z.string() }),
|
|
z.object({
|
|
kind: z.literal('excel'),
|
|
name: z.string(),
|
|
sheet: z.string(),
|
|
rows: z.array(z.array(z.string())),
|
|
}),
|
|
z.object({
|
|
kind: z.literal('word'),
|
|
name: z.string(),
|
|
blocks: z.array(
|
|
z.union([
|
|
z.object({ t: z.enum(['h1', 'h2', 'p']), text: z.string() }),
|
|
z.object({ t: z.literal('ul'), items: z.array(z.string()) }),
|
|
]),
|
|
),
|
|
}),
|
|
z.object({ kind: z.literal('pdf'), name: z.string(), src: z.string() }),
|
|
]);
|
|
export type FileDoc = z.infer<typeof fileDocSchema>;
|
|
export { fileDocSchema };
|
|
|
|
export const viewBlockSchema = z.discriminatedUnion('t', [
|
|
z.object({ t: z.literal('callout'), tone: toneSchema, text: z.string() }),
|
|
z.object({
|
|
t: z.literal('kv'),
|
|
title: z.string().optional(),
|
|
items: z.array(
|
|
z.object({
|
|
k: z.string(),
|
|
v: z.string(),
|
|
/** 绑定某工作事项的回填字段:未答显示占位,作答后实时落定 */
|
|
fromItem: z.object({ item: z.string(), field: z.string() }).optional(),
|
|
}),
|
|
),
|
|
}),
|
|
z.object({
|
|
t: z.literal('badges'),
|
|
title: z.string().optional(),
|
|
items: z.array(z.object({ label: z.string(), on: z.boolean() })),
|
|
}),
|
|
z.object({
|
|
t: z.literal('table'),
|
|
title: z.string().optional(),
|
|
cols: z.array(z.string()),
|
|
rows: z.array(z.array(z.string())),
|
|
editable: z.boolean().optional(),
|
|
editableCols: z.array(z.number().int()).optional(),
|
|
linkDonut: z
|
|
.object({
|
|
col: z.number().int(),
|
|
title: z.string().optional(),
|
|
unit: z.string().optional(),
|
|
map: z.array(z.object({ match: z.string(), label: z.string(), tone: toneSchema })),
|
|
})
|
|
.optional(),
|
|
}),
|
|
z.object({
|
|
t: z.literal('bars'),
|
|
title: z.string().optional(),
|
|
max: z.number().optional(),
|
|
redline: z.number().optional(),
|
|
items: z.array(
|
|
z.object({
|
|
label: z.string(),
|
|
value: z.number(),
|
|
text: z.string().optional(),
|
|
tone: toneSchema.optional(),
|
|
}),
|
|
),
|
|
}),
|
|
z.object({
|
|
t: z.literal('donut'),
|
|
title: z.string().optional(),
|
|
unit: z.string().optional(),
|
|
items: z.array(z.object({ label: z.string(), value: z.number(), tone: toneSchema })),
|
|
}),
|
|
z.object({
|
|
t: z.literal('mail'),
|
|
dir: z.enum(['in', 'out']),
|
|
statusLabel: z.string().optional(),
|
|
editable: z.boolean().optional(),
|
|
from: z.string(),
|
|
fromAddr: z.string().optional(),
|
|
to: z.string(),
|
|
cc: z.string().optional(),
|
|
subject: z.string(),
|
|
time: z.string(),
|
|
body: z.string(),
|
|
attachments: z
|
|
.array(z.object({ name: z.string(), meta: z.string().optional(), file: fileDocSchema.optional() }))
|
|
.optional(),
|
|
}),
|
|
z.object({
|
|
t: z.literal('cards'),
|
|
title: z.string().optional(),
|
|
items: z.array(
|
|
z.object({
|
|
tag: z.string(),
|
|
icon: z.string().optional(),
|
|
tone: toneSchema.optional(),
|
|
title: z.string(),
|
|
code: z.string().optional(),
|
|
swap: z.object({ from: z.string(), to: z.string(), hint: z.string().optional() }).optional(),
|
|
big: z.string(),
|
|
bigLabel: z.string().optional(),
|
|
meta: z.array(z.object({ k: z.string(), v: z.string() })).optional(),
|
|
note: z.string().optional(),
|
|
from: z.string().optional(),
|
|
time: z.string().optional(),
|
|
}),
|
|
),
|
|
}),
|
|
z.object({ t: z.literal('waiting'), label: z.string(), hint: z.string().optional() }),
|
|
z.object({
|
|
t: z.literal('files'),
|
|
title: z.string().optional(),
|
|
items: z.array(z.object({ label: z.string(), desc: z.string(), file: fileDocSchema })),
|
|
}),
|
|
]);
|
|
export type ViewBlock = z.infer<typeof viewBlockSchema>;
|
|
|
|
/* ── 阶段与过程成果 ───────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* 阶段:交互层的统一进展刻度。
|
|
* 引擎层的来源随范式而异(loop = CMMN Stage / 里程碑,flow = 步序位置),前端不感知。
|
|
*/
|
|
export const stageSchema = z.object({
|
|
id: stageIdSchema,
|
|
label: z.string(),
|
|
round: z.number().int().min(1).optional(),
|
|
});
|
|
export type Stage = z.infer<typeof stageSchema>;
|
|
|
|
export const DELIVERABLE_STATES = ['draft', 'pending-acceptance', 'released', 'rejected'] as const;
|
|
export const deliverableStateSchema = z.enum(DELIVERABLE_STATES);
|
|
export type DeliverableState = z.infer<typeof deliverableStateSchema>;
|
|
|
|
export const DELIVERABLE_STATE_LABEL: Readonly<Record<DeliverableState, string>> = {
|
|
draft: '草稿',
|
|
'pending-acceptance': '待验收',
|
|
released: '已放行',
|
|
rejected: '已驳回',
|
|
};
|
|
|
|
/** 过程成果:每个成果都要经验收放行才落地(放行门,OS 09) */
|
|
export const deliverableSchema = z.object({
|
|
id: deliverableIdSchema,
|
|
stage: stageIdSchema,
|
|
round: z.number().int().min(1).optional(),
|
|
title: z.string(),
|
|
desc: z.string().optional(),
|
|
blocks: z.array(viewBlockSchema),
|
|
state: deliverableStateSchema,
|
|
at: z.string().optional(),
|
|
});
|
|
export type Deliverable = z.infer<typeof deliverableSchema>;
|
|
|
|
/* ── 工单两模式(执行 / 验收) ────────────────────────────────────────── */
|
|
|
|
export const WORK_ORDER_MODES = ['execution', 'acceptance'] as const;
|
|
export const workOrderModeSchema = z.enum(WORK_ORDER_MODES);
|
|
export type WorkOrderMode = z.infer<typeof workOrderModeSchema>;
|
|
|
|
export const WORK_ORDER_MODE_LABEL: Readonly<Record<WorkOrderMode, string>> = {
|
|
execution: '执行',
|
|
acceptance: '验收',
|
|
};
|
|
|
|
/** 完成回传:谁、何时、答了什么(回传即留痕) */
|
|
export const workDoneSchema = z.object({
|
|
at: z.iso.datetime(),
|
|
by: staffIdSchema,
|
|
result: z.string(),
|
|
values: z.record(z.string(), z.string()).optional(),
|
|
});
|
|
export type WorkDone = z.infer<typeof workDoneSchema>;
|
|
|
|
const workOrderBase = {
|
|
id: workOrderIdSchema,
|
|
at: z.iso.datetime(),
|
|
title: z.string(),
|
|
/** 调用必附上下文 —— basis 为空的工单基座必须拒发 */
|
|
basis: basisSchema,
|
|
stage: stageIdSchema.optional(),
|
|
done: workDoneSchema.optional(),
|
|
};
|
|
|
|
/** 执行工单:作业指导书 + 空表单(补全并入此类);落地失败卡是它的系统预置形态 */
|
|
export const executionWorkOrderSchema = z.object({
|
|
...workOrderBase,
|
|
mode: z.literal('execution'),
|
|
/** 系统预置形态标记:landing-failure = 落地失败卡(OS 7.3) */
|
|
preset: z.enum(['landing-failure']).optional(),
|
|
ask: z.string(),
|
|
standard: z.string().optional(),
|
|
steps: z.array(z.string()).optional(),
|
|
/** 报告正文取自哪份过程成果 —— 人要在数据上作判断,数据就得摆在表单旁边 */
|
|
basedOn: deliverableIdSchema.optional(),
|
|
blocks: z.array(viewBlockSchema).optional(),
|
|
fields: z.array(formFieldSchema),
|
|
submitLabel: z.string().optional(),
|
|
});
|
|
export type ExecutionWorkOrder = z.infer<typeof executionWorkOrderSchema>;
|
|
|
|
export const acceptanceCriterionCheckSchema = z.object({
|
|
label: z.string(),
|
|
level: acceptanceLevelSchema,
|
|
pass: z.boolean(),
|
|
});
|
|
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
|
|
|
|
/** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */
|
|
export const actionIntentViewSchema = z.object({
|
|
label: z.string(),
|
|
effect: z.enum(['read', 'write']),
|
|
detail: z.string(),
|
|
});
|
|
export type ActionIntentView = z.infer<typeof actionIntentViewSchema>;
|
|
|
|
export const ACTION_EFFECT_LABEL: Readonly<Record<'read' | 'write', string>> = {
|
|
read: '读',
|
|
write: '写',
|
|
};
|
|
|
|
/** 验收工单:AI 拟好的可修订报告 + 判据逐条核对 + 放行三选一 */
|
|
export const acceptanceWorkOrderSchema = z.object({
|
|
...workOrderBase,
|
|
mode: z.literal('acceptance'),
|
|
deliverable: deliverableIdSchema,
|
|
criteria: z.array(acceptanceCriterionCheckSchema),
|
|
actions: z.array(actionIntentViewSchema).optional(),
|
|
/** 报告可修订:字段随成果 schema 渲染,AI 拟值已填 */
|
|
fields: z.array(formFieldSchema).default([]),
|
|
passLabel: z.string().optional(),
|
|
});
|
|
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
|
|
|
|
export const workOrderSchema = z.discriminatedUnion('mode', [
|
|
executionWorkOrderSchema,
|
|
acceptanceWorkOrderSchema,
|
|
]);
|
|
export type WorkOrder = z.infer<typeof workOrderSchema>;
|
|
|
|
/* ── 对话卡族(决策 / 补全 / 评价) ──────────────────────────────────── */
|
|
|
|
/** AI 发言的类型标记 —— 对话区可分辨:结论 / 指导 / 判据 */
|
|
export const AI_TAGS = ['conclusion', 'guidance', 'criterion'] as const;
|
|
export const aiTagSchema = z.enum(AI_TAGS);
|
|
export type AiTag = z.infer<typeof aiTagSchema>;
|
|
|
|
export const AI_TAG_LABEL: Readonly<Record<AiTag, string>> = {
|
|
conclusion: '结论',
|
|
guidance: '指导',
|
|
criterion: '判据',
|
|
};
|
|
|
|
const chatBase = { id: cardIdSchema, at: z.iso.datetime() };
|
|
|
|
/** 决策卡:分支条件选择表;含自定义收尾项 */
|
|
export const decisionCardSchema = z.object({
|
|
...chatBase,
|
|
k: z.literal('decision'),
|
|
ask: z.string(),
|
|
basis: basisSchema,
|
|
recommend: z.string().optional(),
|
|
options: z.array(
|
|
z.object({
|
|
value: z.string(),
|
|
label: z.string(),
|
|
detail: z.string().optional(),
|
|
consequence: z.string().optional(),
|
|
recommended: z.boolean().optional(),
|
|
}),
|
|
),
|
|
/** 自定义收尾项:人可给出选项之外的处置(附理由) */
|
|
allowCustom: z.boolean().default(false),
|
|
chosen: z
|
|
.object({
|
|
value: z.string(),
|
|
at: z.iso.datetime(),
|
|
by: staffIdSchema,
|
|
note: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
export type DecisionCard = z.infer<typeof decisionCardSchema>;
|
|
|
|
/** 补全卡:就地要几个小信息;含「没法填」旁路 —— 两条路必走其一 */
|
|
export const elicitationCardSchema = z.object({
|
|
...chatBase,
|
|
k: z.literal('elicitation'),
|
|
ask: z.string(),
|
|
basis: basisSchema.optional(),
|
|
fields: z.array(
|
|
z.object({ key: z.string(), label: z.string(), placeholder: z.string().optional() }),
|
|
),
|
|
done: z
|
|
.object({
|
|
at: z.iso.datetime(),
|
|
by: staffIdSchema,
|
|
values: z.record(z.string(), z.string()),
|
|
/** 没法填的理由 —— 人让 AI 迭代一轮需求 */
|
|
reason: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
|
|
|
|
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
|
|
export const ratingCardSchema = z.object({
|
|
...chatBase,
|
|
k: z.literal('rating'),
|
|
ask: z.string(),
|
|
basis: basisSchema.optional(),
|
|
done: z
|
|
.object({
|
|
at: z.iso.datetime(),
|
|
by: staffIdSchema,
|
|
score: z.number().int().min(1).max(5),
|
|
note: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
export type RatingCard = z.infer<typeof ratingCardSchema>;
|
|
|
|
export const chatCardSchema = z.discriminatedUnion('k', [
|
|
decisionCardSchema,
|
|
elicitationCardSchema,
|
|
ratingCardSchema,
|
|
]);
|
|
export type ChatCard = z.infer<typeof chatCardSchema>;
|
|
|
|
/* ── 对话流消息 ───────────────────────────────────────────────────────── */
|
|
|
|
export const chatMessageSchema = z.discriminatedUnion('k', [
|
|
z.object({
|
|
...chatBase,
|
|
k: z.literal('ai'),
|
|
text: z.string(),
|
|
tag: aiTagSchema.optional(),
|
|
/** 指涉的工单 id —— 对话区点击即让视图区定位到那张工单 */
|
|
ref: workOrderIdSchema.optional(),
|
|
}),
|
|
z.object({ ...chatBase, k: z.literal('human'), text: z.string(), by: staffIdSchema }),
|
|
z.object({
|
|
...chatBase,
|
|
k: z.literal('system'),
|
|
type: z.enum(['data', 'time', 'human', 'control']),
|
|
text: z.string(),
|
|
}),
|
|
decisionCardSchema,
|
|
elicitationCardSchema,
|
|
ratingCardSchema,
|
|
]);
|
|
export type ChatMessage = z.infer<typeof chatMessageSchema>;
|
|
|
|
/* ── 回传协议(前端 → 后端;后端以同一 schema 校验,§9.2) ───────────── */
|
|
|
|
export const workOrderReplySchema = z.discriminatedUnion('mode', [
|
|
z.object({
|
|
mode: z.literal('execution'),
|
|
workOrderId: workOrderIdSchema,
|
|
values: z.record(z.string(), z.string()),
|
|
}),
|
|
z.object({
|
|
mode: z.literal('acceptance'),
|
|
workOrderId: workOrderIdSchema,
|
|
/** 三选一:放行通过 / 驳回重做 / 驳回迭代 —— 驳回必附意见 */
|
|
verdict: z.union([z.literal('pass'), rejectionKindSchema]),
|
|
/** 报告的人工修订(可空 = 全盘采信 AI 拟值) */
|
|
values: z.record(z.string(), z.string()).default({}),
|
|
comment: z.string().optional(),
|
|
/** 选择组的出口选择:恰发一口,随放行一并确认(AM 1.2) */
|
|
selectedPorts: z.record(z.string(), z.string()).default({}),
|
|
}),
|
|
]);
|
|
export type WorkOrderReply = z.infer<typeof workOrderReplySchema>;
|
|
|
|
export const chatCardReplySchema = z.discriminatedUnion('k', [
|
|
z.object({
|
|
k: z.literal('decision'),
|
|
cardId: cardIdSchema,
|
|
value: z.string(),
|
|
note: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
k: z.literal('elicitation'),
|
|
cardId: cardIdSchema,
|
|
values: z.record(z.string(), z.string()).default({}),
|
|
/** 「没法填」旁路 */
|
|
reason: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
k: z.literal('rating'),
|
|
cardId: cardIdSchema,
|
|
score: z.number().int().min(1).max(5),
|
|
note: z.string().optional(),
|
|
}),
|
|
]);
|
|
export type ChatCardReply = z.infer<typeof chatCardReplySchema>;
|
|
|
|
/**
|
|
* 驳回必附意见 —— 三规矩之一的机检形态(OS 05)。
|
|
* 后端回传校验与前端提交校验共用本函数。
|
|
*/
|
|
export function rejectionNeedsComment(reply: WorkOrderReply): boolean {
|
|
return reply.mode === 'acceptance' && reply.verdict !== 'pass' && !reply.comment?.trim();
|
|
}
|
|
|
|
/** 补全卡两条路必走其一:要么填值,要么给「没法填」的理由 */
|
|
export function elicitationReplyIsEmpty(reply: ChatCardReply): boolean {
|
|
if (reply.k !== 'elicitation') return false;
|
|
const hasValue = Object.values(reply.values).some((v) => v.trim() !== '');
|
|
return !hasValue && !reply.reason?.trim();
|
|
}
|