diff --git a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts index 494c2fd..ed2597a 100644 --- a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts +++ b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts @@ -517,4 +517,26 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] })); expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true); }); + + /* + * **card 后面还有笔 → warn**(2026-08-29 终审 I3 附带):卡真发出去那一拍到此为止, + * 排在后面的笔要等人交回之后的那一拍才跑。写配方的人常按顺序读成「铺格 → 发卡 → 收口整理」 + * 一拍连做 —— 那是读错的,装的时候提前说一声。 + */ + it('card 后面还有笔 → warn(它们要等人交回之后的那一拍才跑)', () => { + const findings = autoRecipeFindings( + withRecipe({ + do: [ + { kind: 'card', as: 'execution', title: 'x' }, + { kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'a' } }, + ], + }), + ); + expect(findings.some((f) => f.severity === 'warn' && f.message.includes('card 后面还有 1 笔'))).toBe(true); + }); + + it('card 是最后一笔:不出这条 warn', () => { + const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] })); + expect(findings.some((f) => f.message.includes('card 后面还有'))).toBe(false); + }); }); diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index b648521..30e23cb 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -471,6 +471,22 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi if (action.ask !== undefined) walkExpr(action.ask, where); if (action.standard !== undefined) walkExpr(action.standard, where); for (const b of action.basis ?? []) walkExpr(b.v, where); + /* + * **card 后面还有笔:说清它们什么时候跑**(2026-08-29 终审 I3 附带的一条 warn)。 + * 卡真发出去那一拍到此为止(`stop`);排在后面的笔要等**人交回之后**的那一拍才跑 + * (那一拍这一笔 `card` 因为 `carded` 被跳过)。写配方的人常按顺序读成 + * 「铺格 → 发卡 → 收口整理」一拍连做 —— 那是读错的,提前说一声。 + */ + if (index < recipe.do.length - 1) { + out.push({ + code: 'schema-invalid', + severity: 'warn', + message: + `card 后面还有 ${recipe.do.length - index - 1} 笔:卡发出去那一拍到此为止,` + + '这几笔要等人交回之后的那一拍才跑(那一拍 card 这一笔被跳过)—— 确认这就是你要的次序', + at: where, + }); + } } /* `say.text` 同样是 AutoExpr,同一个理由要核(2026-08-29 评审补) */ if (action.kind === 'say') { diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index a2b0311..d52e9b5 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -581,3 +581,45 @@ describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)', expect([...drafts.values()].find((d) => d.key['internal_pn'] === 'IN_LIB')?.landed).toBe(true); }); }); + +/** + * **`carded` 只跳过这一笔,不收拍**(2026-08-29 终审 I3)。 + * + * 从前 carded 分支 `stop: true` → break 掉整条 `do`。同一份配方 `[project, card, project]` + * 在两种局面下行为相反:人还在填(quiet)时 card 被 quiet 过滤 `continue` 掉,**第三笔照跑**; + * 人交回之后(非 quiet、carded)走到 carded 分支 break,**第三笔不跑** —— + * 唯一一个「人的答案已经在草稿里」的时刻,配方的后半截偏偏不执行,而且静默、没有痕。 + */ +describe('carded:跳这一笔,不收拍(I3)', () => { + const THREE: AutoRecipe = { + do: [ + { kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A' } }, + { kind: 'card', as: 'execution', title: '请补齐', basis: [{ k: 'a', v: 'b' }] }, + { kind: 'project', into: 'parts.parameter', from: { once: true }, fields: { internal_pn: 'A', param_name: '收口', param_value: '1' } }, + ], + }; + + it('人交回之后那一拍:卡不再发,**卡后面那一笔照跑**', async () => { + const { runner, drafts, cards, ctx } = harness({}); + const out = await runner.run(THREE, { ...ctx, carded: true }); + expect(cards.issued).toHaveLength(0); + expect(out.issuedCardId).toBeUndefined(); + expect([...drafts.values()].map((d) => d.object).sort()).toEqual(['parameter', 'part']); + }); + + it('卡真发出去那一拍仍然收拍 —— 「收拍」留给发得出去的那一次', async () => { + const { runner, drafts, cards, ctx } = harness({}); + const out = await runner.run(THREE, ctx); + expect(cards.issued).toHaveLength(1); + expect(out.issuedCardId).toBe('card-1'); + // 第一笔写了,第三笔没跑 + expect([...drafts.values()].map((d) => d.object)).toEqual(['part']); + }); + + it('人还在填(quiet)那一拍:card 被跳过,前后两笔都跑 —— 两种局面现在一致了', async () => { + const { runner, drafts, cards, ctx } = harness({}); + await runner.run(THREE, { ...ctx, quiet: true, carded: true }); + expect(cards.issued).toHaveLength(0); + expect([...drafts.values()].map((d) => d.object).sort()).toEqual(['parameter', 'part']); + }); +}); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index 25302db..7e8b337 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -211,13 +211,22 @@ export function createAutoRunner(deps: AutoRunnerDeps): { * 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。 */ if (context.carded) { + /* + * **只跳过这一笔,不收拍**(2026-08-29 终审 I3)。从前这里 `stop: true`, + * 于是同一份配方 `[project, card, project]` 在两种局面下行为相反: + * 人还在填(quiet)时 card 被上面那道 quiet 过滤 `continue` 掉 → **第三笔照跑**; + * 人交回之后(非 quiet、carded)→ 走到这儿 break,**第三笔不跑**。 + * 也就是说唯一一个「人的答案已经在草稿里」的时刻,配方的后半截偏偏不执行 —— + * 写配方的人会很自然地写「铺格 → 发卡 → 收口整理」,然后发现第三笔从来没生效过, + * 而且静默、没有痕。**「收拍」该留给真发出去了卡那一次**(下面两条 return 带 stop)。 + */ 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 }; + return { wrote: 0, dropped: 0 }; } const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) })); if (action.as === 'decide') {