Archived
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:
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user