Archived
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:
@@ -101,9 +101,16 @@ function payloadOf(detail: Record<string, unknown> | undefined, limit: number =
|
||||
* 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。
|
||||
*/
|
||||
const chosen = detail['value'];
|
||||
const note = detail['note'];
|
||||
if (typeof chosen === 'string' && chosen !== '') {
|
||||
const note = detail['note'];
|
||||
parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`);
|
||||
} else if (typeof note === 'string' && note !== '') {
|
||||
/*
|
||||
* 没有选项也可能有话要带 —— 零输入执行卡的空答复痕靠这一句说清
|
||||
* 「人已办完并交回」,不然痕上一个值都没有,模型会当「没答成」再发一张卡
|
||||
* (2026-08-02 实测熔断单)。
|
||||
*/
|
||||
parts.push(note);
|
||||
}
|
||||
|
||||
const output = detail['output'];
|
||||
@@ -186,7 +193,47 @@ export function sameQuestion(a: string, b: string): boolean {
|
||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
|
||||
const x = norm(a);
|
||||
const y = norm(b);
|
||||
return x !== '' && x === y;
|
||||
if (x === '' || y === '') return false;
|
||||
if (x === y) return true;
|
||||
/*
|
||||
* **前缀也算同一件事**(2026-08-02 用户实测送来的单):第一张
|
||||
* 「请审定这份受理需求清单,确认无误后我就往下走。」人答了;第二张
|
||||
* 「请审定这份受理需求清单」—— 只少了个尾巴,全等比对放它过去,
|
||||
* 又烧掉一次人环预算(那单 humanCalls 4/4 耗尽,放行卡都没发出去)。
|
||||
* 短的一方够长(≥6 字)且是长方前缀,就是同一问 —— 阈值保守:
|
||||
* 拦错一问比多问一问更坏,真的新问题不该被吃掉。
|
||||
*/
|
||||
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
|
||||
return short.length >= 6 && long.startsWith(short);
|
||||
}
|
||||
|
||||
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
|
||||
export function echoTitleOf(answered: string): string {
|
||||
return `这张卡人已答过,没有再发(「${answered}」)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* **同一张卡该不该再发一遍** —— 执行工单与决策卡共用这一把尺(纯判断,好测)。
|
||||
*
|
||||
* 从前只有执行工单那一路有这道兜底,决策卡**一道都没有**:同一个岔路口连发四张,
|
||||
* 每张扣一次调人预算,四次扣完整单 `exhausted`(2026-08-03 A4 实测:人点完之后
|
||||
* 那一步被重念,剧本头一段写着「发一张决策卡」,它就照办再发一张)。
|
||||
* 卡族不同,病是同一个 —— **按钮按两下不该变成墙**,也不该变成四张卡。
|
||||
*
|
||||
* 三档:`send` 照发;`echo` 卡不真发(人不被打扰、humanCalls 不烧),
|
||||
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
|
||||
*/
|
||||
export function resendVerdict(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
||||
asked: string,
|
||||
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
|
||||
const trimmed = asked.trim();
|
||||
if (trimmed === '') return { kind: 'send' };
|
||||
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
|
||||
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
|
||||
if (same === undefined) return { kind: 'send' };
|
||||
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
|
||||
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,7 +267,7 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
|
||||
return actions.some((a) => HANDOFF_KINDS.has(a.kind));
|
||||
}
|
||||
|
||||
const HANDOFF_KINDS = new Set(['ask', 'human', 'decide', 'listen', 'submit']);
|
||||
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
|
||||
|
||||
export function sameGist(a: string, b: string): boolean {
|
||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
|
||||
@@ -304,11 +351,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
/**
|
||||
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
|
||||
*
|
||||
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走。谁走,分情况:
|
||||
* · 项标了 `manualActivationRule` → **只能 Agent 自己 start**。那条纪律管的是
|
||||
* 触达世界与关键交付:按一下再动,为的是别让 AI 悄悄改了外面。
|
||||
* · 没标 → 引擎就地过一下。为一次读类调用或一次草稿写单烧一拍模型去按那一下,
|
||||
* 是纯仪式;实测里它还会卡死:模型反复挑同一个没 start 的项,每拍都被拓扑拒回来。
|
||||
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走 —— **引擎就地过一下**,
|
||||
* 人工激活的项也一样(2026-08-02 拆墙)。从前人工激活的项要求模型先发一个显式
|
||||
* `start`,不发就拒;可那道墙保护不了任何东西:start 是**模型自己的**动作,
|
||||
* 同一串里连发即过,拦下只是逼它多演一步礼仪 —— 而礼仪学不稳就是死法:
|
||||
* 实测 A1 一单连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回
|
||||
* 都写着这句话,它就是学不会。人工激活真正要保的两样一样不少:
|
||||
* 引擎不因哨兵满足**替它启动**(那在 sentry 那一侧),也不给 doneWhen
|
||||
* **替人收步**(那在自动收步那一侧)。「模型显式动它」本身就是启动的意思表示,
|
||||
* 与 finish 就地过 Active、start 已收项无事发生同族 —— 状态机的礼仪不该长成墙。
|
||||
*
|
||||
* 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过),
|
||||
* 只回一句「(不存在)」它下一拍还是照编。
|
||||
@@ -357,9 +408,6 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
`要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`,
|
||||
);
|
||||
}
|
||||
if (item.manualActivation) {
|
||||
throw new Error(`${itemId} 标了人工激活(触达世界或关键交付),要先 start 再干活 —— 当下是 ${state}`);
|
||||
}
|
||||
return transitionItem(runtime, itemId, 'Active');
|
||||
}
|
||||
|
||||
@@ -667,8 +715,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。
|
||||
*/
|
||||
if (changed === 0) {
|
||||
/*
|
||||
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
|
||||
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
|
||||
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
|
||||
*/
|
||||
throw new Error(
|
||||
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同,局面没有推进 —— 换一件事做`,
|
||||
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
|
||||
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
|
||||
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`,
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
@@ -755,11 +810,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。
|
||||
* 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。
|
||||
*
|
||||
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,人工激活未 start 才拦。
|
||||
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,没在场就地站上
|
||||
* (2026-08-02 起人工激活的项同样如此 —— 动它就算启动)。
|
||||
*/
|
||||
const { parsed: cardModel } = await loaded(taskId);
|
||||
const advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
/*
|
||||
* **重发的判断要走在站上场之前**(2026-08-03,A1 熔断实测逼出来的顺序)。
|
||||
*
|
||||
* 原先先 `readyToWork` 再判重发:而人答完之后那一步常常已经**收掉**了,
|
||||
* `readyToWork` 对 Completed 项直接抛「这一步已经办完,不能再往它上面挂活」——
|
||||
* 于是「重发已答过的卡 = 无事发生」这条唯一的活路**在最需要它的时候够不着**,
|
||||
* 模型原样重发、连拒五拍熔断(A1 基线实测:13 拍 124k,同一条拒回重复 5 遍)。
|
||||
* 重发一张已答过的卡本来就不需要那一项在场 —— 它什么也不做。
|
||||
* 「墙要配路」(设计律第一条):先给路,给不了路再谈墙。
|
||||
*
|
||||
* **同一件事不许问第二遍**(2026-08-01,实测账)。
|
||||
*
|
||||
* 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。
|
||||
@@ -775,19 +838,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const asked = String(face['title'] ?? face['ask'] ?? '').trim();
|
||||
if (asked !== '') {
|
||||
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
const sent = window.filter((t) => t.kind === 'card-sent');
|
||||
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
|
||||
const same = sent.find((t) => replied.has(t.title) && sameQuestion(t.title, asked));
|
||||
if (same !== undefined) {
|
||||
throw new Error(
|
||||
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
|
||||
'答案就在你的轨迹里(card-reply 那一条的 values)。' +
|
||||
'**他把格子留空提交回来,就是对方给不了** —— 那也是一个答案:' +
|
||||
'如实记账(问过了 / 答过了),缺口照实留着,把这一步收掉往下走;' +
|
||||
'真有别的东西要问,换一件事、换一个问法。',
|
||||
);
|
||||
const verdict = resendVerdict(window, asked);
|
||||
if (verdict.kind !== 'send') {
|
||||
const same = { title: verdict.answered };
|
||||
/*
|
||||
* **重发已答过的卡:头两次当无事发生,不当被拒**(2026-08-02 用户实测
|
||||
* 送来的熔断单)。从前这里直接拒 —— 拒回的话把「答案在轨迹里」说得
|
||||
* 再清楚,模型偶发就是读不进:零输入执行卡的空答复在它眼里像「没答成」,
|
||||
* 于是原样重发、被拒、再重发,五拍熔断,人被无谓叫来。
|
||||
* 拒回喂的是对抗,观察喂的是事实 —— 与 start 已收项无事发生、
|
||||
* finish 已收项无事发生同族:**按钮按两下不该变成墙**。
|
||||
* 卡不真发(人不被打扰、humanCalls 不烧),只把「已答过」的事实
|
||||
* 连着下一步指路摆回去;第三次起仍按被拒算 —— 那时是真卡死了,
|
||||
* 熔断兜底要看得见它。
|
||||
*/
|
||||
const echoTitle = echoTitleOf(same.title);
|
||||
if (verdict.kind === 'reject') {
|
||||
throw new Error(
|
||||
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
|
||||
'答案就在你的轨迹里(card-reply 那一条)。**空答复 = 人照原样确认了**,' +
|
||||
'不是没答:接着办说明书里「人交回之后」的那几件事,把这一步收掉往下走;' +
|
||||
'真有别的东西要问,换一件事、换一个问法。',
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: echoTitle,
|
||||
detail: {
|
||||
internal: true,
|
||||
note: '人已答过这张卡(空答复 = 照原样确认)。别再发了 —— 接着办说明书里「人交回之后」的那几件事,办完把这一步 finish 掉。',
|
||||
},
|
||||
});
|
||||
// 什么也没做,局面原样交回 —— 这一拍不该顺手把那一项推上场
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
}
|
||||
const { parsed: cardModel } = await loaded(taskId);
|
||||
const advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
@@ -806,32 +894,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
|
||||
case 'ask': {
|
||||
/*
|
||||
* 发补全卡 = 立监听听人:卡发到对话区,本拍就此挂起等填。
|
||||
*
|
||||
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡 ——
|
||||
* 卡收不回,负责人的对话区就堆起一摞谁也不知道该怎么答的表单。
|
||||
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
|
||||
* 执行卡完全就是包含补全语义的人类工具」)。归一层(agent-runner)已把
|
||||
* `ask` 原地转成零输入执行工单,这个分支正常到不了 —— 真到了
|
||||
* (别的调用方直发)就如实拒,并指对路。
|
||||
*/
|
||||
if (action.fields.length === 0) {
|
||||
throw new Error(
|
||||
'补全卡要有格子:一个缺口一个格子,形状 fields:[{key,label,hint?}] —— 空表单等于没问',
|
||||
);
|
||||
}
|
||||
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
||||
const card = await deps.cards.ask({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
humanCallCap: context.humanCallCap,
|
||||
ask: action.ask,
|
||||
fields: action.fields,
|
||||
...(action.basis === undefined ? {} : { basis: action.basis }),
|
||||
});
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
eventType: 'human',
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: advanced, status: 'awaiting' };
|
||||
throw new Error('补全卡已退役:缺信息发零输入执行工单(human + workOrder:"execution"),指导书写清缺什么,人在工作区的件上补');
|
||||
}
|
||||
|
||||
case 'entry': {
|
||||
@@ -875,7 +943,38 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。
|
||||
*/
|
||||
if (action.options.length < 2) {
|
||||
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者发补全卡');
|
||||
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者派一张执行工单');
|
||||
}
|
||||
/*
|
||||
* 同款顺序:**先判重发,再站上场**(见 human 那一路的账 —— 人选完之后
|
||||
* 那一步常已收掉,先站上场就会撞在「已办完」的墙上,活路够不着)。
|
||||
*
|
||||
* **人已经选过的岔路口不再发第二张**(2026-08-03,A4 实测:同一张连发四次,
|
||||
* 四次调人预算烧光落 `exhausted`)。与执行工单同一把尺 —— 决策卡的身份是问句(`ask`),
|
||||
* 它入痕时记的正是 `ask`(`issueChatCard` → `card-sent` 标题)。
|
||||
*/
|
||||
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
const verdict = resendVerdict(window, action.ask);
|
||||
if (verdict.kind !== 'send') {
|
||||
const echoTitle = echoTitleOf(verdict.answered);
|
||||
if (verdict.kind === 'reject') {
|
||||
throw new Error(
|
||||
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
||||
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
|
||||
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: echoTitle,
|
||||
detail: {
|
||||
internal: true,
|
||||
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
|
||||
},
|
||||
});
|
||||
// 什么也没做,局面原样交回
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
||||
const card = await deps.cards.decide({
|
||||
@@ -931,11 +1030,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
/*
|
||||
* 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。
|
||||
*
|
||||
* 没站上场的普通项**就地过 Active 再收**(readyToWork 同一套):这一项的活
|
||||
* 没站上场的项**就地过 Active 再收**(readyToWork 同一套):这一项的活
|
||||
* 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」
|
||||
* 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍
|
||||
* start S6 + finish 原样重来,白花一拍)。标了人工激活的仍要先 start:
|
||||
* 那条纪律管的是触达世界与关键交付,readyToWork 会替它把门。
|
||||
* start S6 + finish 原样重来,白花一拍)。人工激活的项也一样(2026-08-02
|
||||
* 拆墙,见 readyToWork 注释)—— 但它仍旧**没有 doneWhen、引擎不自动收**,
|
||||
* 收它的只能是模型自己的这一下 finish。
|
||||
*/
|
||||
const { parsed: finishModel } = await loaded(taskId);
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user