feat(loop-engine): card 与 submit 两个动词;审核卡放宽到单选项

ADR-014 §8 原写「不发决策卡、不 submit」—— 那是假设还有 agent 在场时的保守话。
全程无 agent 的活动没有别人来干这两件事,不开口最后两步就没人做。
护栏没松:submit 仍要过提交门(判据全绿),系统提交不比模型提交宽松。
发过的卡不再发第二张;发了卡就收拍,排在后面的笔一律不跑。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 04:08:59 +08:00
co-authored by Claude Opus 5
parent fe8de5319c
commit e9da8d25f5
4 changed files with 185 additions and 5 deletions
@@ -333,3 +333,21 @@ describe('人工验收清单:点全才能通过', () => {
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
});
});
describe('审核卡的选项数', () => {
it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => {
const findings = checkChatCard({ k: 'decision', ask: '往哪走', basis: [{ k: 'a', v: 'b' }], options: [{ value: 'a', label: 'A' }] } as never);
expect(findings.some((f) => f.code === 'options-missing')).toBe(true);
});
it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => {
const findings = checkChatCard({
k: 'decision',
variant: 'review',
ask: '核对过了吗',
basis: [{ k: 'a', v: 'b' }],
options: [{ value: 'approve', label: '确认通过' }],
} as never);
expect(findings.some((f) => f.code === 'options-missing')).toBe(false);
});
});
@@ -89,7 +89,17 @@ export function checkChatCard(card: ChatCard): CardViolation[] {
switch (card.k) {
case 'decision':
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });
/*
* **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 ——
* 改全在件上就地做,卡上只有「确认通过」一个动作。
* 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。
*/
{
const floor = card.variant === 'review' ? 1 : 2;
if (card.options.length < floor) {
out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' });
}
}
break;
case 'elicitation':
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
@@ -12,6 +12,7 @@ type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; inst
function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
const notes: string[] = [];
const cards = { issued: [] as Array<Record<string, unknown>> };
const view = (): Record<string, unknown> => {
const mine: Record<string, unknown[]> = {};
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
@@ -37,8 +38,8 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
},
},
cards: {
issue: () => Promise.resolve({ id: 'card-1' }),
decide: () => Promise.resolve({ id: 'card-2' }),
issue: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); },
decide: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); },
entry: () => Promise.resolve({ id: 'card-3' }),
ask: () => Promise.resolve({ id: 'card-4' }),
unanswered: () => Promise.resolve([]),
@@ -58,7 +59,7 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
};
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
return { runner: createAutoRunner(deps as never), drafts, notes, ctx };
return { runner: createAutoRunner(deps as never), drafts, notes, cards, ctx };
}
describe('配方执行器', () => {
@@ -246,3 +247,95 @@ describe('analyze 动词', () => {
expect(called).toBe(0);
});
});
describe('card 与 submit', () => {
it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => {
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
const out = await runner.run(
{
do: [
{
kind: 'card',
as: 'execution',
variant: 'data',
title: { cat: ['数据工单 · 补齐 ', { in: 'pn' }] },
guide: ['选一个芯片类型', '把参数值填齐'],
standard: '必填格全部填上,红带消失',
basis: [{ k: '候选料号', v: { in: 'pn' } }],
gateLedgers: ['parts.profile', 'parts.parameter'],
},
],
},
ctx,
);
expect(out.issuedCardId).toBe('card-1');
expect(cards.issued[0]).toMatchObject({
workOrder: 'execution',
itemId: 'S1',
card: {
mode: 'execution',
title: '数据工单 · 补齐 XL2596S-5.0',
variant: 'data',
steps: ['选一个芯片类型', '把参数值填齐'],
standard: '必填格全部填上,红带消失',
basis: [{ k: '候选料号', v: 'XL2596S-5.0' }],
gateLedgers: ['parts.profile', 'parts.parameter'],
fields: [],
},
});
});
it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => {
const { runner, notes, ctx } = harness({});
const out = await runner.run(
{ do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] },
{ ...ctx, carded: true },
);
expect(out.issuedCardId).toBeUndefined();
expect(notes.some((n) => n.includes('已经发过'))).toBe(true);
});
it('decide + review:摆成验收工单,单一选项也发得出去', async () => {
const { runner, ctx } = harness({});
const out = await runner.run(
{
do: [
{
kind: 'card',
as: 'decide',
variant: 'review',
title: '差异评估结论审核',
ask: '逐条核对过了吗?',
options: [{ value: 'approve', label: '确认通过' }],
basis: [{ k: '候选', v: 'XL2596S-5.0' }],
},
],
},
ctx,
);
expect(out.issuedCardId).toBe('card-2');
});
it('发了卡就**收拍**:排在后面的笔一律不跑', async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(
{
do: [
{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] },
{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } },
],
},
ctx,
);
expect(drafts.size).toBe(0);
});
it('submit:回给 turn-runner,理由现算;有卡在等人时不 submit', async () => {
const { runner, ctx } = harness({ n: 3 });
const out = await runner.run({ do: [{ kind: 'submit', rationale: { cat: ['已判定 ', { in: 'n' }, ' 条参数'] } }] }, ctx);
expect(out.submit).toEqual({ rationale: '已判定 3 条参数' });
const quiet = await runner.run({ do: [{ kind: 'submit', rationale: 'x' }] }, { ...ctx, quiet: true });
expect(quiet.submit).toBeUndefined();
});
});
@@ -165,7 +165,66 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
return { wrote: 0, dropped: 0 };
}
throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`);
if (action.kind === 'card') {
/*
* **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** ——
* 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。
*/
if (context.carded) {
await deps.memory.append({
taskId: context.taskId,
kind: 'observation',
title: `这一步已经发过卡,不再发第二张`,
detail: { auto: `${context.stepId}#${index + 1}`, internal: true },
});
return { wrote: 0, dropped: 0, stop: true };
}
const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) }));
if (action.as === 'decide') {
const options = action.options ?? [];
if (options.length === 0) throw new AutoRecipeError(index, '决策卡一个选项都没有');
const { id } = await deps.cards.decide({
taskId: context.taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
ask: evalText(action.ask ?? action.title, base),
options: options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }) })),
basis,
itemId: context.stepId,
/* `CardGateway.decide` 的 variant 只认 'review'(审核卡);card.variant 的类型口子更宽(还有 'data'),
那是给执行工单用的 —— 这里按值窄化,别把 'data' 误传给决策卡 */
...(action.variant === 'review' ? { variant: action.variant } : {}),
});
return { wrote: 0, dropped: 0, cardId: id, stop: true };
}
const { id } = await deps.cards.issue({
taskId: context.taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: 'execution',
itemId: context.stepId,
card: {
mode: 'execution',
title: evalText(action.title, base),
ask: evalText(action.title, base),
basis,
/* 零输入:填报全在工作区的件上(2026-08-02 用户定) */
fields: [],
...(action.variant === 'data' ? { variant: 'data' } : {}),
...(action.guide === undefined ? {} : { steps: [...action.guide] }),
...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }),
},
});
return { wrote: 0, dropped: 0, cardId: id, stop: true };
}
if (action.kind === 'submit') {
return { wrote: 0, dropped: 0, submit: { rationale: evalText(action.rationale, base) }, stop: true };
}
/* 五个动词(say/project/analyze/card/submit)上面已全部处理,这里在类型上不可达 —— 留作穷尽性兜底 */
throw new AutoRecipeError(index, '动词尚未实现');
}
for (const [index, action] of recipe.do.entries()) {