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([]);
|
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) {
|
switch (card.k) {
|
||||||
case 'decision':
|
case 'decision':
|
||||||
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
|
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;
|
break;
|
||||||
case 'elicitation':
|
case 'elicitation':
|
||||||
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
|
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 } = {}) {
|
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 drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
|
const cards = { issued: [] as Array<Record<string, unknown>> };
|
||||||
const view = (): Record<string, unknown> => {
|
const view = (): Record<string, unknown> => {
|
||||||
const mine: 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 });
|
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: {
|
cards: {
|
||||||
issue: () => Promise.resolve({ id: 'card-1' }),
|
issue: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); },
|
||||||
decide: () => Promise.resolve({ id: 'card-2' }),
|
decide: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); },
|
||||||
entry: () => Promise.resolve({ id: 'card-3' }),
|
entry: () => Promise.resolve({ id: 'card-3' }),
|
||||||
ask: () => Promise.resolve({ id: 'card-4' }),
|
ask: () => Promise.resolve({ id: 'card-4' }),
|
||||||
unanswered: () => Promise.resolve([]),
|
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 };
|
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
||||||
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
|
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
|
||||||
return { runner: createAutoRunner(deps as never), drafts, notes, ctx };
|
return { runner: createAutoRunner(deps as never), drafts, notes, cards, ctx };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('配方执行器', () => {
|
describe('配方执行器', () => {
|
||||||
@@ -246,3 +247,95 @@ describe('analyze 动词', () => {
|
|||||||
expect(called).toBe(0);
|
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 };
|
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()) {
|
for (const [index, action] of recipe.do.entries()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user