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-MVP/apps/api/src/app/seed-mvp-slots.ts
T
ChaiHaoandClaude Opus 5 545103af1b A6 三个去向改选择组,「恰选一口」全链打通 —— 整条流第一次真正跑完
G4 是整条流最后的断点:三个去向写成了必交口里的 outcome 字段 + selective: [],
而引擎只在选择组恰发一口时才注入 outcome —— 结构上永远没有判据。

## 四层贯通

① 契约:验收工单加 `portChoice`(组名 + 可选出口)。draft 是 Omit 透传,human-tool 零改动。
② A6 声明:outcome 移出必交口,三个去向成选择组(won/revise/lost 各一口带人读说明)。
   契约 0.2.0、包版 0.7.0。G4 三条边一行没改 —— outcome 由放行选口注入落地事实。
③ 引擎:验收发卡时从活动契约现读选择组上卡(组是契约的事实,不在步的卡面里声明)。
④ 前端 AcceptForm:选择组渲染为必选单选,不选「通过」不亮;选中的口随放行回传。

## 「恰选一口」拦在收单,不拦在落地 —— 拦错位置实测翻过车

第一版把 validateSelection 放进 release.land:回传是**同步收单**(200 = 收到裁决),
落地在推进器里**异步**跑 —— 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
改到 checkWorkOrderReply(与「驳回必附意见」同族的收单规矩):工单投影自带 portChoice,
放行回传缺口/口不在组内 → 400 同步拒收,工单还活着,人补选就是。
land 里的 validateSelection 留作兜底 —— 这条从 contracts 导出以来从未被执行的不变量,
现在两个岗位都有了。

## 实测四连

  不选口通过 → 400「放行前先选『议价结果』」,工单未被吃掉
  选 won    → end_won released,流 completed —— **整条流第一次真正跑完**
  选 revise → A4#2 重开新实例(A4#1 留痕不动)
  选 lost   → A7 情报归档 → end_arch released,流 completed
  全程 route-dead-end 事件 0 条

typecheck ✓ lint ✓ depcruise ✓ 498 测试 ✓

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-28 16:37:59 +08:00

774 lines
28 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.
/**
* 六个活动槽位(docs/MVP示例工作流.html §1.1–1.7)—— **契约就绪、实现留白**。
*
* 槽位 = 活动的契约面:身份 / 七件契约(入参、出口)/ 声明(域对象、工具名)/ 业务模型骨架。
* 与工具的解耦就在声明:`declarations.tools` 只写**名**,注册、连接值、白名单全在治理侧。
* A5 定价审批是**内置审批点**(审批不是任务,零开发、不入库),不在槽位之列。
*
* 实现待底座完备后逐个开发:届时以新包版本经安装通道换入,槽位契约不动。
*
* **占位期六个槽位一律 `flow` 范式**(2026-07-28):A2/A3/A6 本该是案例活动(loop),
* 但 loop 由 Agent 每拍决策、要真调模型;占位本身就是固定序列,声明成 flow 才诚实。
* 真开发它们为案例活动时,以新包版本装回 `paradigm: 'loop'`,那时按 CMMN + DMN 重写业务模型
* —— 所以这里不留一份没人用的 loop 骨架占地方。
*/
import { createHash } from 'node:crypto';
import { activityManifestSchema } from '@contracts';
import type { ActivityManifest } from '@contracts';
import type { Container } from './container.ts';
import { declarationsFor } from './seed-mvp-data.ts';
/**
* 槽位占位入口 —— **不是实现**:装载它只会得到「未实现」的诚实报错。
* mock 只许在外界:这里绝不发既定载荷装作跑过(README 平移规则)。
*/
export const SLOT_ARTIFACT = [
'// 活动槽位:契约已就绪,实现待底座完备后逐个开发。',
"throw new Error('活动尚未实现 —— 这是槽位,契约见治理台活动库');",
].join('\n');
const checksum = createHash('sha256').update(SLOT_ARTIFACT).digest('hex');
/** 一步机器落地:调占位工具,把它的出参按映射落成台账的一行 */
type Landing = {
/** 人读的步名 */
readonly label: string;
readonly domain: string;
readonly object: string;
/** 主键列 → 取值路径(占位工具出参的键,或作用域里的键) */
readonly key: Readonly<Record<string, string>>;
/** 业务列 → 取值路径 */
readonly fields: Readonly<Record<string, string>>;
};
/**
* 占位期的业务模型骨架 —— **机器步落地 + 一张验收单要人签**。
*
* 为什么是这个形状:
*
* ① **必须写台账**。出口载荷只从落地事实推导(数组字段 ← 这一单的落地行,
* 标量字段 ← 落地行铺平后的值)。不写台账就发不出口 → 下游网关没有判据 →
* 一条边都选不中 → 流无声停在第一个活动(实测过,并有 route-dead-end 事件写明缺哪个键)。
*
* ② **末步必须是验收工单**。`flow-runner` 写死了 `released = 末步是验收 && 裁决通过` ——
* 末步不是验收,活动永远不 release:人把前面的单都答完,任务却停在 `awaiting-human`,
* 没有待答工单也没有待答卡,**等人却没东西给人答**(实测)。
*
* ③ **占位不替活动做判断**。落的值来自占位工具,它只穿透上游给的值、给显眼的默认,
* 行上写 `status: '占位'`。打标、定价、比价那些是活动自己的内容,真实现时整段换掉。
*
* ④ **验收单可以改写落地值**。人类步的回传值同样经 `writeOutput` 落台账(flow-runner 的
* resume 路径),步序在后、后写赢 —— 占位期要走哪个分支,在验收时说,不必改代码。
*
* 一步只写**一行**(`writeOutput` 每步一次 draftWrite),所以要几行就给几个 landing。
*/
const placeholderSkeleton = (
sop: readonly string[],
landings: readonly Landing[],
/** 台账列名 → 回传字段名 */
ask: Readonly<Record<string, string>>,
) => {
const total = landings.length + 1;
const failure = { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' as const } };
const source = { k: '来源', v: '活动槽位占位产出(不做业务判断;真实现时整段换掉)' };
const machine = landings.map((l, i) => ({
name: `S${i + 1}`,
label: l.label,
/* 入参映射:占位工具要的键从任务数据取 —— 上游给了就穿过去 */
input: {
rfqRef: 'rfqRef',
customerCode: 'customerCode',
pn: 'pn',
quotedAt: 'quotedAt',
needAlternative: 'needAlternative',
needSourcing: 'needSourcing',
},
kind: 'pure-tool' as const,
tool: 'slot.placeholder',
output: { domain: l.domain, object: l.object, key: l.key, fields: l.fields },
failure,
}));
return {
paradigm: 'flow' as const,
steps: [
...machine,
{
name: `S${total}`,
label: '收口验收',
input: {},
kind: 'human' as const,
workOrder: 'acceptance' as const,
card: {
title: '收口验收',
/*
* 卡面要人填的正是**这个活动该产出、而占位产不出**的判断值(如两枚去向旗)。
* 填了就覆盖占位行的同一列 —— 于是下游网关判的是人的决定,不是占位的默认。
*/
...(Object.keys(ask).length === 0 ? {} : { fields: Object.values(ask) }),
basis: [
{ k: '步序', v: `第 ${total} 步 / 共 ${total} 步(收口)` },
{ k: '作业指导书', v: sop.join(' · ') },
{ k: '占位落地', v: landings.map((l) => `${l.domain}.${l.object}`).join('、') },
source,
],
},
/* 验收也要把值落回同一行:人填的覆盖占位的 */
...(landings[landings.length - 1] === undefined
? {}
: {
output: {
domain: landings[landings.length - 1]!.domain,
object: landings[landings.length - 1]!.object,
key: landings[landings.length - 1]!.key,
fields: ask,
},
}),
deadline: 'P1D',
failure,
},
],
parallelGroups: [],
};
};
/**
* 各活动占位期要落的台账行 —— 按各自契约出口的必填字段倒推。
*
* 数组字段(records / rows / floors)由「这一单的全部落地行」整体构成,所以只要有行就有值;
* 标量字段必须有**对应的列**才取得到 —— 这就是为什么 A1/A2 要落「受理结论」那一行。
* 一步一行,所以列在这里的条数就是机器步的步数。
*/
const LANDINGS: Readonly<Record<string, readonly Landing[]>> = {
'rfq-intake': [
{
label: '落统一受理记录',
domain: 'project',
object: 'intake_record',
key: { customer_code: 'customerCode', pn: 'pn' },
fields: { status: 'status' },
},
{
// 两枚去向旗落这一行:一单一行,G1/G2 读同一个值(挂按料号分的行会 last-write-wins)
label: '落受理结论(去向旗)',
domain: 'project',
object: 'intake_verdict',
key: { rfq_ref: 'rfqRef' },
fields: { need_alternative: 'needAlternative', need_sourcing: 'needSourcing', status: 'status' },
},
],
'alt-selection': [
{
label: '落替代对标表',
domain: 'project',
object: 'alt_benchmark',
key: { original_pn: 'pn', candidate_pn: 'pn' },
fields: { alt_grade: 'status' },
},
{
// A2 回写**同一行**的 needSourcing —— 去向只有一个出处,不复制
label: '回写受理结论(是否还要寻源)',
domain: 'project',
object: 'intake_verdict',
key: { rfq_ref: 'rfqRef' },
fields: { need_sourcing: 'needSourcing', status: 'status' },
},
],
'sourcing-inquiry': [
{
label: '落寻源比价表',
domain: 'project',
object: 'sourcing_compare',
key: { pn: 'pn', supplier: 'supplier' },
fields: { quote_price: 'quotePrice', verdict: 'status' },
},
{
label: '落供应商底价表',
domain: 'project',
object: 'supplier_floor',
key: { pn: 'pn', supplier: 'supplier' },
fields: { floor_price: 'floorPrice' },
},
],
'cost-pricing': [
{
label: '落报价草案',
domain: 'project',
object: 'quote_draft',
key: { pn: 'pn' },
fields: { proposed_price: 'proposedPrice' },
},
],
'quote-negotiation': [
{
label: '落正式报价单',
domain: 'project',
object: 'formal_quote',
key: { pn: 'pn' },
fields: { unit_price_cny: 'unitPrice' },
},
],
'intel-archive': [
{
label: '落报价落地率统计',
domain: 'project',
object: 'landing_stats',
key: { period: 'period' },
fields: { landing_rate: 'landingRate' },
},
],
};
/**
* 验收单要人填的值 —— **这个活动该产出、而占位产不出的判断**。
* 填了就覆盖占位行的同一列,下游网关判的是人的决定。
* A6 的三个去向不在这儿:那是选择组(恰发一口),由放行时选口给出。
*
* 形状是「**台账列名 → 回传字段名**」,两边不能混:
* 列名必须是小写蛇形(台账的标识符闸是 `^[a-z][a-z0-9_]*$`),
* 回传字段名是卡面表单的键、跟着活动契约的驼峰走。
* 我把这两个搞混过一次:`fields: { needSourcing: 'needSourcing' }` 直接把驼峰当列名,
* draftWrite 抛「非法标识符:needSourcing」,而且是在放行之后抛 —— 任务卡在 running、
* 台账一行没有,推进器还在一秒一次地重试。
*/
const ASK: Readonly<Record<string, Readonly<Record<string, string>>>> = {
'rfq-intake': { need_alternative: 'needAlternative', need_sourcing: 'needSourcing' },
'alt-selection': { need_sourcing: 'needSourcing' },
'sourcing-inquiry': {},
'cost-pricing': {},
'quote-negotiation': {},
'intel-archive': {},
};
const identity = (activityId: string, name: string, paradigm: 'flow' | 'loop') => ({
activityId,
name,
/*
* 包版本 —— 改了槽位携带的东西就必须往上走。
*
* 「安装恒为新增、版本并存」是硬不变量:同 (activityId, version) 再装是 no-op,
* 库里的 manifest 一个字都不会动。所以 0.1.0 期间把卡面从空卡改成自述卡,
* 重播种会「静默成功」—— 打印「已装入」,库里还是旧的(实测踩到)。
*
* 0.2.0:步骤卡面自述(标题 / 要你答 / 步序依据)。
* 0.3.0:人工调用预算按各槽位自己的步数给 —— 共用 3 会让 6 步的 A1 走到第 3 步就
* `exhausted` 终态、结构上永远走不完(实测)。授权面申报值同源。
* 0.4.0:flow 骨架末步追加**收口验收**—— 没有它活动永远不 release,
* 六步答完就卡在「等人却没东西给人答」(实测)。
* 0.5.0:A1/A2 声明「受理结论」对象(两枚去向旗有处落地);A2 入参加 rfqRef。
* 0.6.0:占位期骨架改**机器步落台账 + 一张验收单**(用户定:只留验收要人签)。
* 六个槽位一律 flow 范式;预算固定 humanCalls: 2。
* 0.6.1:修验收步的出参映射 —— 键是**台账列名**(小写蛇形),不是回传字段名。
* 0.7.0:A6 的三个去向改**选择组**(契约 0.2.0)—— outcome 由放行选口给出,G4 才判得了。
* 契约的入参出口一直没动,故 `contract.version` 始终 0.1.0。
* 在途任务按启动时版本跑完,不受影响。
*/
packageVersion: '0.7.0',
category: '统一报价',
paradigm,
sdkVersion: '0.1.0-m5',
checksum,
});
const harness = { memoryWindow: 20, stallAfter: 'PT45M', nudge: [] };
/** 七件契约里逐槽位相同的空段 —— 槽位期没有必交付物/验收细则,实现期再补 */
const contractRest = { requiredDeliverables: [], acceptanceCriteria: [], prohibitions: [] };
/**
* 各槽位的步/项清单 —— **预算与骨架共用同一个数组**,数不上不会漂移。
* flow 是声明式人类步(一步一张工单);loop 的项由 Agent 临场推进,
* 最坏情况一项问一次人,所以也按项数给预算。
*/
const STEPS = {
rfqIntake: ['接收登记', '追问补全信息', '逐条解析归一', '查历史价', '逐条定状况标', '定去向与派单'],
altSelection: ['替代对标表', '初筛清单'],
sourcingInquiry: ['寻源比价表', '供应商回价单'],
costPricing: ['取价基准', '逐条定价'],
quoteNegotiation: ['正式报价单'],
intelArchive: ['结果登记', '画像更新', '落地率统计'],
} as const;
/**
* 预算 —— `humanCalls` 必须**够这个槽位实际要问人的次数**。
*
* 曾经六个槽位共用 `humanCalls: 3`,而那时 A1 有 6 个人类步:走到第 3 步就 `exhausted`
* 终态、工作流转 abnormal,活动**结构上永远走不完**(实测:used 3 / cap 3,停在 A1)。
* 现在占位期是**机器步 + 一张验收单**,只问人一次,所以固定给 2(1 张验收 + 1 次余量)。
* 这条约束本身不随形态变:实现期活动自己声明真实预算时,实际要问人的次数 ≤ humanCalls。
*/
const budgetFor = () => ({
steps: 40,
tokens: 200_000,
activeMinutes: 60,
humanCalls: 2,
});
/**
* 授权面申报的人工调用预算 —— 与运行时真正吃的那个数**同源**。
* 运行时只认 `resources.budget.humanCalls`(见 `wiring/engines.ts` 的 `caps`);
* `authorization.humanCallBudget` 只给治理台显示。两者不一致,治理台就会摆一个假数。
*/
const authorizationFor = () => ({
discretion: [],
preauthorizedActions: [],
humanCallBudget: budgetFor().humanCalls,
});
export const MVP_SLOTS: readonly ActivityManifest[] = [
/* ── A1 客户RFQ受理(SOP · BD) ──────────────────────────────────── */
activityManifestSchema.parse({
identity: identity('rfq-intake', '客户RFQ受理', 'flow'),
contract: {
activityId: 'rfq-intake',
version: '0.1.0',
input: {
data: {
type: 'object',
required: ['customerCode', 'rfqRef'],
properties: {
customerCode: { type: 'string' },
rfqRef: { type: 'string' },
subject: { type: 'string' },
body: { type: 'string' },
quotedAt: { type: 'string' },
},
},
messageAllowed: true,
},
output: {
mandatory: [
{
name: 'intake',
description: '统一受理记录(打标)+ 两枚去向旗',
payload: {
type: 'object',
required: ['records', 'needAlternative', 'needSourcing'],
properties: {
records: { type: 'array' },
pn: { type: 'string' },
type_tag: { type: 'string' },
needAlternative: { type: 'boolean' },
needSourcing: { type: 'boolean' },
},
},
},
],
selective: [],
},
...contractRest,
authorization: authorizationFor(),
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor() },
},
declarations: {
models: [],
listeners: [],
harness,
tools: [{ name: 'crm.history-price-read' }],
ledger: declarationsFor([
'project.rfq_file',
'project.intake_record',
'project.pn_class',
// 受理结论:两枚去向旗落在这里(一单一行,G1/G2 读同一个值)
'project.intake_verdict',
'project.alt_request',
'project.inquiry_request',
'intel.history_price',
'customer.profile',
]),
},
assets: {
businessModel: placeholderSkeleton(STEPS.rfqIntake, LANDINGS['rfq-intake']!, ASK['rfq-intake']!),
skills: [],
viewTemplates: [],
cardSchemas: {},
distillHooks: [],
},
samples: [
{
name: '契约样例:受理一单',
input: { customerCode: 'C001', rfqRef: 'RFQ-001' },
expect: { ports: ['intake'] },
},
],
entry: { file: 'index.js', checksum },
}),
/* ── A2 替代选型(Case · FAE) ──────────────────────────────────── */
activityManifestSchema.parse({
identity: identity('alt-selection', '替代选型', 'flow'),
contract: {
activityId: 'alt-selection',
// 0.2.0:入参加 rfqRef —— 办结时要回写「受理结论」那一行,主键就是它
version: '0.2.0',
input: {
data: {
type: 'object',
required: ['material', 'rfqRef'],
properties: {
material: { type: 'string' },
chip_type: { type: 'string' },
spec_params: { type: 'string' },
scenario: { type: 'string' },
needSourcing: { type: 'boolean' },
/*
* 询价单号。声明它是为了**不偷渡**:边上映射一个契约没声明的字段,
* 闭合校验的类型检查会因 `targetType === undefined` 直接跳过 —— 不报错,
* 但那是默许不是正当,而且下一个读代码的人无从知道 A2 还收这个字段。
*/
rfqRef: { type: 'string' },
},
},
messageAllowed: true,
},
output: {
mandatory: [
{
name: 'benchmark',
description: '替代对标表(已签字)+ needSourcing 原样带出(G2 在本路径读它)',
payload: {
type: 'object',
required: ['rows', 'needSourcing'],
properties: { rows: { type: 'array' }, needSourcing: { type: 'boolean' } },
},
},
],
selective: [],
},
...contractRest,
authorization: authorizationFor(),
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['FAE'], budget: budgetFor() },
},
declarations: {
models: [],
listeners: [],
harness,
tools: [{ name: 'crm.history-price-read' }],
// 受理结论同在声明里:A2 要把 needSourcing 回写**同一行**,去向只有一个出处
ledger: declarationsFor([
'intel.chip_spec',
'intel.public_select',
'project.alt_benchmark',
'project.intake_verdict',
]),
},
assets: {
businessModel: placeholderSkeleton(STEPS.altSelection, LANDINGS['alt-selection']!, ASK['alt-selection']!),
skills: [],
viewTemplates: [],
cardSchemas: {},
distillHooks: [],
},
samples: [
{
name: '契约样例:一件替代评估',
input: { material: 'LM358DR' },
expect: { ports: ['benchmark'] },
},
],
entry: { file: 'index.js', checksum },
}),
/* ── A3 寻源询价(Case · PRM) ──────────────────────────────────── */
activityManifestSchema.parse({
identity: identity('sourcing-inquiry', '寻源询价', 'flow'),
contract: {
activityId: 'sourcing-inquiry',
version: '0.1.0',
input: {
data: {
type: 'object',
required: ['pn'],
properties: {
pn: { type: 'string' },
brand: { type: 'string' },
era: { type: 'string' },
project_stage: { type: 'string' },
accept_alt: { type: 'boolean' },
},
},
messageAllowed: true,
},
output: {
mandatory: [
{
name: 'compare',
description: '寻源比价表 + 供应商底价表',
payload: {
type: 'object',
required: ['rows', 'floors'],
properties: { rows: { type: 'array' }, floors: { type: 'array' } },
},
},
],
selective: [],
},
...contractRest,
authorization: authorizationFor(),
resources: {
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
skills: [],
models: [],
roles: ['PRM'],
budget: budgetFor(),
},
},
declarations: {
models: [],
listeners: [],
harness,
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
ledger: declarationsFor([
'supplier.channel_dir',
'supplier.dead_stock',
'project.sourcing_compare',
'project.supplier_floor',
]),
},
assets: {
businessModel: placeholderSkeleton(STEPS.sourcingInquiry, LANDINGS['sourcing-inquiry']!, ASK['sourcing-inquiry']!),
skills: [],
viewTemplates: [],
cardSchemas: {},
distillHooks: [],
},
samples: [
{
name: '契约样例:一单询价',
input: { pn: 'LM358DR' },
expect: { ports: ['compare'] },
},
],
entry: { file: 'index.js', checksum },
}),
/* ── A4 成本定价(SOP · BD) ────────────────────────────────────── */
activityManifestSchema.parse({
identity: identity('cost-pricing', '成本定价', 'flow'),
contract: {
activityId: 'cost-pricing',
version: '0.1.0',
input: {
data: {
type: 'object',
required: ['records'],
properties: {
records: { type: 'array' },
floors: { type: 'array' },
benchmark: { type: 'array' },
customerCode: { type: 'string' },
},
},
messageAllowed: true,
},
output: {
mandatory: [
{
name: 'draft',
description: '报价草案:逐条定价,先过底价门禁再提审',
payload: {
type: 'object',
required: ['rows'],
properties: { rows: { type: 'array' }, quote_no: { type: 'string' } },
},
},
],
selective: [],
},
...contractRest,
authorization: authorizationFor(),
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor() },
},
declarations: {
models: [],
listeners: [],
harness,
tools: [{ name: 'crm.history-price-read' }],
ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.quote_draft']),
},
assets: {
businessModel: placeholderSkeleton(STEPS.costPricing, LANDINGS['cost-pricing']!, ASK['cost-pricing']!),
skills: [],
viewTemplates: [],
cardSchemas: {},
distillHooks: [],
},
samples: [
{
name: '契约样例:一批草案',
input: { records: [] },
expect: { ports: ['draft'] },
},
],
entry: { file: 'index.js', checksum },
}),
/* ── A6 报价议价(Case · BD) ───────────────────────────────────── */
activityManifestSchema.parse({
identity: identity('quote-negotiation', '报价议价', 'flow'),
contract: {
activityId: 'quote-negotiation',
version: '0.2.0',
input: {
data: {
type: 'object',
required: ['rows'],
properties: { rows: { type: 'array' }, quote_no: { type: 'string' }, customerCode: { type: 'string' } },
},
messageAllowed: true,
},
output: {
mandatory: [
{
name: 'formal',
description: '正式报价单(已送出)+ 跟进提醒',
payload: {
type: 'object',
properties: {
quote_no: { type: 'string' },
customer_code: { type: 'string' },
rows: { type: 'array' },
},
},
},
],
/*
* 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口,
* 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。
* 从前写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组
* 恰发一口时才注入 outcome,没有选择组就永远没有 outcome,G4 三条边全不成立。
*/
selective: [
{
group: '议价结果',
ports: [
{ name: 'won', description: '成交 —— 转订单履约', payload: { type: 'object' } },
{ name: 'revise', description: '有异议 —— 退回重新定价', payload: { type: 'object' } },
{ name: 'lost', description: '拒绝或超时 —— 归档闭环', payload: { type: 'object' } },
],
},
],
},
...contractRest,
authorization: authorizationFor(),
resources: {
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
skills: [],
models: [],
roles: ['BD'],
budget: budgetFor(),
},
},
declarations: {
models: [],
listeners: [],
harness,
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
ledger: declarationsFor(['project.formal_quote', 'project.followup']),
},
assets: {
businessModel: placeholderSkeleton(STEPS.quoteNegotiation, LANDINGS['quote-negotiation']!, ASK['quote-negotiation']!),
skills: [],
viewTemplates: [],
cardSchemas: {},
distillHooks: [],
},
samples: [
{
name: '契约样例:一单议价',
input: { rows: [] },
expect: { ports: ['formal'] },
},
],
entry: { file: 'index.js', checksum },
}),
/* ── A7 情报归档(SOP · BD) ────────────────────────────────────── */
activityManifestSchema.parse({
identity: identity('intel-archive', '情报归档', 'flow'),
contract: {
activityId: 'intel-archive',
version: '0.1.0',
input: {
data: {
type: 'object',
required: ['outcome'],
properties: {
outcome: { type: 'string' },
quote_no: { type: 'string' },
customer_code: { type: 'string' },
quotedAt: { type: 'string' },
},
},
messageAllowed: true,
},
output: {
mandatory: [
{
name: 'stats',
description: '不成交也留资产:情报、客户画像、落地率',
payload: {
type: 'object',
required: ['landing_rate'],
properties: { landing_rate: { type: 'number' } },
},
},
],
selective: [],
},
...contractRest,
authorization: authorizationFor(),
resources: { tools: [], skills: [], models: [], roles: ['BD'], budget: budgetFor() },
},
declarations: {
models: [],
listeners: [],
harness,
tools: [],
ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.landing_stats']),
},
assets: {
businessModel: placeholderSkeleton(STEPS.intelArchive, LANDINGS['intel-archive']!, ASK['intel-archive']!),
skills: [],
viewTemplates: [],
cardSchemas: {},
distillHooks: [],
},
samples: [
{
name: '契约样例:一次归档',
input: { outcome: 'lost' },
expect: { ports: ['stats'] },
},
],
entry: { file: 'index.js', checksum },
}),
];
export async function installSlots(container: Container): Promise<void> {
for (const manifest of MVP_SLOTS) {
const { activityId, packageVersion, name, paradigm } = manifest.identity;
/*
* 先问库里有没有这一版 —— 因为「安装恒为新增、版本并存」:同版再装是 no-op,
* manifest 一个字不动。不问就报「已装入」等于**撒谎**:改了槽位内容重播种,
* 屏幕说装好了,库里还是旧的,人拿着旧行为查新代码(实测踩到,那次是卡面没换)。
*/
const already = await container.registry
.manifestOf(activityId, packageVersion)
.then(() => true)
.catch(() => false);
const verdict = await container.registry.install(manifest, SLOT_ARTIFACT);
const errors = verdict.staticFindings.filter((finding) => finding.severity === 'error');
process.stdout.write(
` 槽位 ${name}(${paradigm})${already ? ` v${packageVersion} 已在库,未改动 —— 要换内容请升包版` : ` v${packageVersion} 已装`}` +
` · 静态审 ${verdict.staticFindings.length} 条` +
(errors.length > 0 ? ` —— ERROR: ${errors.map((e) => e.message).join('; ')}` : '') +
'\n',
);
}
}