harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条

human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 23:20:16 +08:00
co-authored by Claude Fable 5
parent 909b1fe4b7
commit 7b85a79323
79 changed files with 3903 additions and 1297 deletions
@@ -43,6 +43,23 @@ export function phaseProgress(
if (terminal) return { index: total, total, name: CLOSING_PHASE };
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
/*
* **被跳过的项不许把进度条钉死**(2026-08-27 用户实测:都在库时数据工单两项一直是 Available,
* 从前「一段里的项全收完才算过」让条永远停在 1/6,而单子早已办到审核了)。
* 现在先看「案子走到了哪一段」—— 最后一个有项**动过**(Active / Completed / Terminated)的阶段;
* 那一段全收完了就算过、进下一段,没收完就停在那一段。一段都没动过才退回老规矩(第一段)。
*/
const touched = new Set(
items.filter((i) => i.state === 'Active' || i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
);
let last = -1;
for (const [at, phase] of phases.entries()) if (phase.items.some((id) => touched.has(id))) last = at;
if (last >= 0) {
const passed = phases[last]!.items.every((id) => settled.has(id));
const current = passed ? last + 1 : last;
const phase = phases[current];
return phase === undefined ? { index: total, total, name: CLOSING_PHASE } : { index: current + 1, total, name: phase.name };
}
for (const [at, phase] of phases.entries()) {
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
}
@@ -387,6 +387,15 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
/**
* **这一步是不是终局放行那一步**(2026-08-27,W05 实测:模型在 T_release 先发了一张执行工单
* 「评审放行与落账确认」,人答完它才 submit —— 同一件事问了人两遍)。
* 认法:剧本给它声明了人工验收清单、又不是审核卡那一步 —— 放行卡由 `submit` 发,不该再有别的卡。
*/
export function isReleaseStep(step: { acceptanceChecklist?: ReadonlyArray<string> | undefined; cardVariant?: string | undefined } | undefined): boolean {
return step !== undefined && step.acceptanceChecklist !== undefined && step.acceptanceChecklist.length > 0 && step.cardVariant !== 'review';
}
export function sameGist(a: string, b: string): boolean {
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
const x = norm(a);
@@ -643,6 +652,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
*/
hasFollowUp = false,
/**
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
*/
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
): Promise<{
runtime: CaseRuntime;
status: TurnOutcome['status'];
@@ -650,6 +664,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
saved?: true;
/** 这个行动自己已经把画面摆好了 —— 回合末别再盖一帧上去 */
framed?: true;
/** 这个行动发了一张卡给人,卡是哪一步的 —— 回合末的画面跟着它走(见 runTurn 推帧那段) */
issuedFor?: string;
}> {
const context = await deps.tasks.of(taskId);
// 项号先归一(简写认得出),再往下走 —— 一处归一,所有行动型都受用
@@ -1118,6 +1134,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return { runtime, status: 'running' };
}
}
/*
* **放行那一步不发工单**(2026-08-27 W05 实测):模型在 T_release 发了一张执行工单
* 「评审放行与落账确认」,人答完它才 submit,验收卡又来一张 —— 同一件事问人两遍。
* 放行卡由 submit 发(基座预检后发验收卡),这一步没有别的卡可发;拒回把路指清。
*/
if (faceOf?.(action.itemId)?.release === true) {
throw new Error(
'这一步是终局放行:放行卡由 submit 发(基座预检后自己发验收卡),不发执行工单 / 数据工单 —— ' +
'发了等于把同一件事问人两遍(先一张执行单、再一张验收单)。这一拍改成:judge 该裁的裁量判据,然后 submit。',
);
}
const { parsed: cardModel } = await loaded(taskId);
/*
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
@@ -1161,6 +1188,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return {
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
status: 'awaiting',
issuedFor: action.itemId,
};
}
@@ -1204,7 +1232,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
keyFilter: { cardId: entryCard.id },
deadline: 'P3D',
});
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting' };
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting', issuedFor: action.itemId };
}
case 'decide': {
@@ -1249,9 +1277,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return { runtime, status: 'running' };
}
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
const face = faceOf?.(action.itemId);
const card = await deps.cards.decide({
taskId,
itemId: action.itemId,
...(face?.variant === undefined ? {} : { variant: face.variant }),
...(face?.checklist === undefined ? {} : { checklist: face.checklist }),
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
ask: action.ask,
@@ -1265,7 +1296,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting' };
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting', issuedFor: action.itemId };
}
case 'listen': {
@@ -1514,13 +1545,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
detail: { internal: true },
});
}
/*
* 验收卡的题目用放行那一步在案例模型里的名字(如「评审放行与落账确认(ACT-GATE-03)」)——
* 「收口验收」是没声明时的兜底,人看见的应该是活动自己给这一步起的名。
*/
const { model: releaseModel, parsed: releaseParsed } = await loaded(taskId);
const gateStep = releaseModel.steps?.find((st) => isReleaseStep(st)) ?? releaseStep;
const releaseName = gateStep === undefined ? undefined : releaseParsed.items.find((i) => i.id === gateStep.item)?.name;
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: 'acceptance',
card: {
title: '收口验收',
title: releaseName === undefined || releaseName.trim() === '' ? '收口验收' : releaseName,
basis: [{ k: '提交理由', v: rationale }],
...(fields === undefined || fields.length === 0 ? {} : { fields: fields.map((f) => ({ ...f })) }),
},
@@ -1610,6 +1648,26 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
});
}
/*
* **叫了人就等人**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。
*
* 这一单还有卡没交回,这一拍就不问模型 —— 不管是谁叫醒的:迟到的回传事件、另一台后端的
* 推进器、手动 wake。从前每一次唤醒都跑一拍,而事实行对模型说「这一步已经发过卡,人已经被
* 问过了」,它就照「人交回之后」那一段把步骤收掉 —— 没人答的卡就这样被跳过(实测:S1 的卡
* 发出 0.3 秒后,上一张卡回传的事件才投到,再一拍就「完成:评估输入与资料补齐确认」)。
* 在途验收(上面那段)先于它:验收裁决本身就是那张卡的回传。
*/
const waiting = await deps.cards.unanswered(taskId);
if (waiting.length > 0) {
await deps.memory.append({
taskId,
kind: 'observation',
title: `等人交回:${waiting.map((c) => c.title).join('、')} —— 这一拍不动`,
detail: { internal: true, cards: waiting.map((c) => c.id) },
});
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
}
/*
* 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行)。
*
@@ -1772,22 +1830,37 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 只收 Active/Available 的项;已经收了的不重复收。收完重算 enabled(收掉一项
* 可能让下一项的前提成立),循环到收不动为止 —— 封顶 8 轮,防剧本写出环。
*/
let stateRuntime = runtime;
let stateEnabled = enabled;
if (steps.length > 0) {
/**
* 在场的项 = 当下可为 ∪ 进行中 —— 两摞都要看:
* 成果写完但还没 finish 的那一项正躺在「进行中」里,它才是最该被自动收的。
*
* **人工激活的项:人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」)。
* 从前一律不收 —— 怕引擎替人签字(T_release 触达世界,收它得人按一下)。可 W05 的两张数据工单实测:
* 卡人答了、数据也入库了,「这一步办完了没有」是引擎手里现成的事实,却让模型去记得 finish ——
* 剧本两处白纸黑字「先 finish 再发下一张」,它照样忘,两步同时 Active、件停在上一步、人无处可填。
* 设计律:**指南让模型判的事,引擎有现成事实的,一律改成读事实。**
* 门槛仍在:`worldTouched`(卡真发过)—— 卡没发过的人工激活项 doneWhen 再真也不收,那才是替人签字;
* 走到这儿时没有未答的卡(前面 `cards.unanswered` 把住),拍尾那一遍另把这一拍刚发卡的项排除。
* 没声明 doneWhen 的人工激活项(T_release / 审核循环 / 交人补缺口)一个字不变。
*/
const sweepRipe = async (
runtimeIn: CaseRuntime,
enabledIn: ReturnType<typeof publishEnabled>,
scopeIn: typeof scope,
exclude?: string,
): Promise<{ runtime: CaseRuntime; enabled: ReturnType<typeof publishEnabled> }> => {
let runtimeNow = runtimeIn;
let enabledNow = enabledIn;
if (steps.length === 0) return { runtime: runtimeNow, enabled: enabledNow };
for (let round = 0; round < 8; round += 1) {
/*
* 在场的项 = 当下可为 ∪ 进行中 —— 两摞都要看:
* 成果写完但还没 finish 的那一项正躺在「进行中」里,它才是最该被自动收的。
* **人工激活的项不自动收**(T_release 触达世界,收它得人按一下)。
*/
const onStage = [...stateEnabled.entries, ...stateEnabled.inProgress];
const onStage = [...enabledNow.entries, ...enabledNow.inProgress];
const ripe = onStage.find((e) => {
if (e.requiresManualStart) return false;
if (e.id === exclude) return false;
if (e.requiresManualStart && runtimeNow.items.find((i) => i.id === e.id)?.worldTouched !== true) return false;
const step = stepOf(e.id);
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return false;
try {
return deps.sfeel.condition(step.doneWhen, scope);
return deps.sfeel.condition(step.doneWhen, scopeIn);
} catch {
// 谓词写坏了不该拖垮整拍:当作「没算出来」,照旧交给模型
return false;
@@ -1796,8 +1869,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (ripe === undefined) break;
const step = stepOf(ripe.id);
try {
const active = readyToWork(parsed, stateRuntime, ripe.id);
stateRuntime = transitionItem(active, ripe.id, 'Completed');
const active = readyToWork(parsed, runtimeNow, ripe.id);
runtimeNow = transitionItem(active, ripe.id, 'Completed');
} catch {
break;
}
@@ -1807,11 +1880,13 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
title: `自动收步:${ripe.id} 的成果已在台账里(${step?.doneWhen ?? ''})—— 不必再问模型`,
detail: { internal: true, item: ripe.id, doneWhen: step?.doneWhen },
});
stateEnabled = publishEnabled(parsed, stateRuntime, scope, changedRefs(scope), deps.sfeel.condition, [
...stateEnabled.gaps,
]);
enabledNow = publishEnabled(parsed, runtimeNow, scopeIn, changedRefs(scopeIn), deps.sfeel.condition, [...enabledNow.gaps]);
}
}
return { runtime: runtimeNow, enabled: enabledNow };
};
const swept = await sweepRipe(runtime, enabled, scope);
const stateRuntime = swept.runtime;
const stateEnabled = swept.enabled;
/*
* 当前这一步 = 当下可为里**第一个有剧本的项**(CMMN 的项序即业务次序)。
@@ -1850,6 +1925,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const declared = parsed.items.find((i) => i.id === current.item);
if (declared?.manualActivation !== true) return '';
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
/*
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
*/
if (isReleaseStep(current)) {
return touched
? '这一步是终局放行:验收卡已经发出去了,人签字之前不要再动台账、不要再发任何卡;被打回重做照剧本改完再 submit。'
: '这一步是终局放行(触达世界):**唯一合法的动作是 submit(提交放行)** —— 基座预检后自己发验收卡。' +
'不要发执行工单 / 数据工单 / 决策卡,也不要立监听(发了会被拒,而且等于把同一件事问人两遍)。' +
'先 judge 该裁的裁量判据,再 submit,同一拍连做。';
}
return touched
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
'照下面「人交回之后」那一段办。'
@@ -2058,6 +2144,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
let saved = false;
let framed = false;
let lastAction: AgentAction | undefined;
/** 这一拍发出去的卡是哪一步的 —— 画面跟卡走 */
let issuedFor: string | undefined;
/*
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
@@ -2077,6 +2165,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
reason?: string;
saved?: true;
framed?: true;
issuedFor?: string;
};
try {
// AI 不得产生终局迁移 —— start 一个不存在的项按被拒处理,不掀翻整拍
@@ -2103,6 +2192,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
}
},
index < planned.length - 1,
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
(itemId) => {
const named = itemId === undefined ? undefined : stepOf(itemId);
const step = named?.cardVariant !== undefined || named?.acceptanceChecklist !== undefined ? named : (current ?? named);
const variant = step?.cardVariant === 'review' ? ('review' as const) : undefined;
const checklist = step?.acceptanceChecklist;
const release = isReleaseStep(step);
if (variant === undefined && checklist === undefined && !release) return undefined;
return {
...(variant === undefined ? {} : { variant }),
...(checklist === undefined ? {} : { checklist }),
...(release ? { release: true as const } : {}),
};
},
);
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
} catch (error) {
@@ -2120,6 +2223,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (applied.reason !== undefined) blockedReason = applied.reason;
if (applied.saved === true) saved = true;
if (applied.framed === true) framed = true;
if (applied.issuedFor !== undefined) issuedFor = applied.issuedFor;
// 挂起型落地即收拍:卡已发 / 监听已立 / 验收已提 —— 这一拍到此为止
if (applied.status !== 'running') break;
// 工具出参要先看见:同拍余下的行动是闭着眼计划的,作废并留痕
@@ -2134,6 +2238,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
}
}
/*
* **拍尾再扫一遍**(2026-08-27 晚):人答卡的那一拍里模型写完东西,上一步的 doneWhen 往往这时才真
* (值是人在件上填的、旗是这一拍记的);不等下一拍 —— 下一拍可能要等人再答一张卡才来,
* 而画面与进度条在这之间都得对(实测:P-08 没收、P-10 的卡发了,人对着 P-08 的件无处可填)。
* 提交放行那一拍(saved)不扫:它已连在途验收一起落盘,再存一次会把信封擦掉。
* 这一拍刚发卡的项排除(卡还没人答,收它就是替人签字)。
*/
if (!saved) {
if (scopeStale) {
liveScope = await deps.data.view(taskId);
scopeStale = false;
}
const enabledTail = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, [...stateEnabled.gaps]);
currentRuntime = (await sweepRipe(currentRuntime, enabledTail, liveScope, issuedFor)).runtime;
}
// 已经自己落过笔的行动(提交放行连在途验收一起存)不再兜底保存 —— 见 submit 分支的注释
if (!saved) await deps.state.save(taskId, model, parsed, currentRuntime);
@@ -2181,11 +2300,22 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
.filter((e) => !settledNow.has(e.id))
.map((e) => stepOf(e.id))
.find((x) => x !== undefined);
const wantedNow = stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted;
/*
* **画面跟着这一拍发出去的卡走**(2026-08-27 晚,用户送来 3ef3fc3e:「当前这一步不是应该给我推
* P-10 的数据工单吗?怎么还是推送的芯片主数据创建,这样我都没法填数据了」)。
* 上面那条「按办完之后在办的那一步」在**两步同时 Active** 时仍会挑错:模型没收掉 S0d_p08(剧本白纸黑字
* 写了「先 finish 再发下一张」,两处;措辞治不了),直接发了 S0d_p10 的数据工单 —— 「第一步没干完的」
* 算出来还是 P-08,人拿着 P-10 的卡对着 P-08 的件,提交闸拦「缺资料」而屏上没有资料表可填。
* 卡是发给人的那件事,件必须是那件事的件:这一拍发了哪一步的卡(human / entry / decide),
* 那一步声明了画面就推它,压过别的规则;没发卡的拍照旧。
*/
const issuedStep = issuedFor === undefined ? undefined : stepOf(issuedFor);
const issuedView = issuedStep?.view !== undefined && issuedStep.view !== '' ? issuedStep.view : undefined;
const wantedNow = issuedView ?? (stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted);
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
if (!framed && frame !== undefined && frame !== '') {
try {
const shown = await showView(taskId, frame, currentRuntime, stepNow?.view ?? liveStep?.view);
const shown = await showView(taskId, frame, currentRuntime, issuedView ?? stepNow?.view ?? liveStep?.view);
if (!shown.pushed) {
await deps.memory.append({
taskId,