fix(loop-engine): 收尾复审二审 —— 兜底卡自己会抛 / 清单兜底不看面孔 / 空转写解闸

复审逐条核实上一波(C2/C3/M1/M2)修法本身引入的三处破坏,全部属实并修死:

- I1 二审:配方办不下去时发的那张兜底卡(deps.cards.issue)从前裸调,调人预算
  触顶抛 HumanCallBudgetExceededError 会精确复现 C3 那种无限热循环(推进器每
  2 秒重来、不烧预算、不换状态、界面纹丝不动)。包进 try/catch,失败留痕、
  状态退回 awaiting,不让它抛出去。配了变异验证:拿掉 try/catch 后新用例
  真的变红(runTurn 抛出预算触顶错误),还原后转绿。
- C2 二审:decisionCardFace 的 checklist 兜底从前不看 reviewFace —— 一步只声明
  acceptanceChecklist 而 cardVariant 不是 review(T_release 那种形状)时,配方发
  的普通 decide 卡也会被兜上清单,前端不渲染勾选区、回传门却要求 checked ⊇
  checklist,卡按不下去。改成只在 reviewFace 为真时才退回剧本声明的清单。
- M1/M2 二审:project 动作里 localExisting/localUnkeyed 触发的观察痕与真写
  (localWrote/localDropped)共用 detail.auto + detail.ledger 这两个键,而
  raiseAcceptance 的 revised 判据只认这两个键在不在 —— 一颗料 skipExisting、
  或一行主键算不出来,这条"什么都没动"的痕照样能解开重做闸,人打回重做、
  配方原样重跑一遍,闸却开了。auto-runner 补 detail.changed(真写了/真撤了
  才为真),revised 判据对配方那一路改认这个标记。

另两笔记账:
- save-wake.test.ts 的用例标题写着"验收态就是这一种(没有卡在等人)"是错的,
  pendingCards 把未办结工单也算在内,验收态恰恰有一张开着的验收工单——只改
  标题措辞,不动断言与实现。
- docs/debt.md 补两笔:保存后唤醒的收窄几乎不生效(pendingCards 口径过宽);
  模型路径的写同样会 un-land、只补了配方那一半(独立起一条,排在 W05 重写前)。

pnpm gates 退出码 0(2410 passed / 23 skipped);
git diff --stat b995148...HEAD -- activities/ 为空,九个活动包一字未动。

报告:.superpowers/sdd/2026-08-29-deterministic-steps-auto-recipe/closing-fix-report.md

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 09:21:02 +08:00
co-authored by Claude Opus 5
parent c60c882f06
commit c3fed573ee
8 changed files with 252 additions and 34 deletions
@@ -621,6 +621,46 @@ describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)',
});
});
/**
* **`detail.changed` 只在真写了/真撤了时为真**(2026-08-29 复审 M1/M2 二审)。
*
* M1/M2 让「一行没写」也出声(`localExisting`/`localUnkeyed`),而**台账其实一个字没动**——
* 重做闸(`turn-runner.raiseAcceptance` 的 `revised`)靠这个标记分清「配方真改过台账」
* 与「配方跑过一遍但什么也没落」,两者都会留痕,只有前者该解锁闸。
*/
describe('project 留痕:changed 只在真写了/真撤了时为真(M1/M2 二审)', () => {
it('真写了一行:changed:true', async () => {
const { runner, appended, ctx } = harness({});
await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A' } }] },
ctx,
);
const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part');
expect(note?.detail?.['changed']).toBe(true);
});
it('全在库、这一笔不用建(skipExisting):changed:false —— 台账没动', async () => {
const { runner, formal, appended, ctx } = harness({});
formal.add('sub_eval.part|W05-1|IN_LIB');
await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'IN_LIB' } }] },
ctx,
);
const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part');
expect(note?.detail?.['changed']).toBe(false);
});
it('主键算不出来、一行都没写:changed:false —— 台账没动', async () => {
const { runner, appended, ctx } = harness({});
await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: '' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
ctx,
);
const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part');
expect(note?.detail?.['changed']).toBe(false);
});
});
/**
* **`carded` 只跳过这一笔,不收拍**(2026-08-29 终审 I3)。
*