A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了

活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 12:28:31 +08:00
co-authored by Claude Opus 5
parent 85466e815f
commit ade0fa5a94
90 changed files with 17969 additions and 2316 deletions
+81 -6
View File
@@ -246,6 +246,7 @@ export function missingReadings(
primaryKey: boolean;
required?: boolean | undefined;
expect?: boolean | undefined;
requiredWhen?: { column: string; value: string } | undefined;
}>;
}>,
): Record<string, string | number> {
@@ -260,17 +261,23 @@ export function missingReadings(
* · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准);
* · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。
* 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。
*
* `requiredWhen` 把必填钉在**该轮到的那一行**(2026-08-02,逐颗派单的闸):
* 行上那一列不等于指定值,这一格就还没轮到,不算缺 —— 排队的颗不拦当前的卡。
*/
for (const [mark, suffix] of [
['required', 'missing'],
['expect', 'gaps'],
] as const) {
const watched = fields.filter((f) => f[mark] === true);
const watched = fields.filter((f) => f[mark] === true || (mark === 'required' && f.requiredWhen !== undefined));
if (watched.length === 0) continue;
const bad: string[] = [];
for (const row of rows) {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const gaps = watched.filter((f) => blank(one, f.name));
const due = watched.filter(
(f) => f.requiredWhen === undefined || String(one[f.requiredWhen.column] ?? '') === f.requiredWhen.value,
);
const gaps = due.filter((f) => blank(one, f.name));
if (gaps.length === 0) continue;
const id: Record<string, string> = {};
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
@@ -323,6 +330,40 @@ export function tallyReadings(
return out;
}
/**
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
*
* 「机检只读基座数」这条纪律此前只覆盖到台账(`_rows` / `_missing` / tally),
* **覆盖不到工具**:一条判据若要说「转 A 的依据来自本单亲查的行情」,
* 从前只能读活动自报的列 —— 而模型偶发压根没调工具就把料判成「行情稳」,
* 自报的那一列照样写得漂亮(A2 实测约十次一次,去向就此走岔)。
* 说明书已经写到头(「这一拍亲自查 alt_pn」「不许拿上游的数顶替」),
* **措辞治不了的结构治**(设计律第六条)。
*
* 工具出参本来就入痕(`turn-runner` 每次调完记一条 `kind:'result'`、
* 标题 `${tool} 返回`),所以「调没调」是**基座数得出来的事实**,不必让活动自报。
*
* 只数**成功返回**的那几次:调失败等于没查到行情,不该拿来当依据。
* 名字取 `tool_<工具名去符号>_calls`(S-FEEL 不认点号与横杠);
* 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假,
* 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。
*/
export function toolCallReadings(
trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
declared: ReadonlyArray<{ readonly name: string }>,
): Record<string, number> {
const key = (tool: string): string => `tool_${tool.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`;
const out: Record<string, number> = {};
for (const { name } of declared) out[key(name)] = 0;
for (const entry of trail) {
if (entry.kind !== 'result') continue;
for (const { name } of declared) {
if (entry.title.startsWith(`${name} 返回`)) out[key(name)] = (out[key(name)] ?? 0) + 1;
}
}
return out;
}
export function rowKeyList(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>,
@@ -752,8 +793,19 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。
* 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。
*/
const declared = (await deps.registry.manifestOf(task.activityId)).declarations.ledger;
return { ...scope, ...rowReadings(mine, declared), ...rowKeyList(mine, declared), ...missingReadings(mine, declared), ...tallyReadings(mine, declared), ...mine };
const manifest = await deps.registry.manifestOf(task.activityId);
const declared = manifest.declarations.ledger;
// 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的
const trail = await deps.memory.window(taskId, 400);
return {
...scope,
...rowReadings(mine, declared),
...rowKeyList(mine, declared),
...missingReadings(mine, declared),
...tallyReadings(mine, declared),
...toolCallReadings(trail, manifest.declarations.tools),
...mine,
};
},
/**
* 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。
@@ -1447,7 +1499,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } });
return { id: submitted.workOrderId };
}
const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }];
/*
* **执行卡不带表单字段**(2026-08-02 用户定:「填写全去工作区」)——
* 填报的地方是工作区那份件(editGate + 孪生件 + 增删行),卡上只剩
* 指导书、依据与提交钮。从前这里兜底一格必填「结果」textarea:
* 活动没声明字段就替它造一个,人对着一张没处下笔的卡还得编一句话才按得下提交。
*/
const fields = declaredFields ?? [];
/*
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
*
@@ -1859,7 +1917,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
},
})
: createAgentRunner({ gateway: tracedGateway, onUsage: onAgentUsage, modelOf });
: createAgentRunner({
gateway: tracedGateway,
onUsage: onAgentUsage,
modelOf,
/*
* 输出不成形就地重问时,把模型原文存进工作日志(内部痕)——
* 卡面从此不摆 JSON 原话,尸检去日志里看。stamp 与 memoryRecorder 同款兜底。
*/
note: async (taskId, title, raw) => {
await deps.memory.append({
taskId,
kind: 'result',
title,
detail: { internal: true, raw },
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
});
},
});
/* ── loop-engine ──────────────────────────────────────────────── */
const loop = createTurnRunner({