Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions packages/cli/src/commands/review/agent-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3445,6 +3445,10 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
expect(out).toContain('2 auditors required this round');
expect(out).toContain('chunk 13 — retired: dry in rounds 1 and 2');
expect(out).toContain('certificate final');
// Pin the spelled cap number, not just the branch: a hardcoded `5-round
// cap leaves` in the note wording would otherwise ship silently and tell
// the orchestrator a false cap on exactly the huge-diff runs this targets.
expect(out).toContain('3-round cap leaves');
expect(out).not.toContain('next cold check round 4');
});

Expand Down Expand Up @@ -3472,6 +3476,19 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
.join('\n');
expect(msg).toContain('ROUND CAP');
expect(msg).toContain('round cap is 3');
// The load-bearing tail rules — the same verify-only / compose-floor
// contract the budget message's test pins and SKILL.md's round-cap
// bullet mirrors; a reword that drops any of these silently loosens
// the termination contract, so pin each.
expect(msg).toContain('agent-prompt --role verify');
expect(msg).toContain('never a hand-rolled agent');
expect(msg).toContain('compose floor');
expect(msg).toContain('Do NOT re-verify findings already');
Comment on lines +3483 to +3486

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The round-cap refusal's two new bounded-tail clauses are pinned only against reverseAuditBudgetMessage (deadline.test.ts:481-482), not at this round-cap refusal site — the deadline.test.ts comment this diff adds claims "both pin both", but the round-cap side of that symmetry does not exist. — Failure scenario: probe-verified mutation — deleting both stop waiting on any verifier batch still out and invent a fresh re-verification pass from the round-cap refusal in agent-prompt.ts alone ships green (agent-prompt.test.ts 13 passed, deadline.test.ts 43 passed, SKILL.test.ts 7 passed), silently loosening the bounded-tail termination contract on exactly the huge-diff cap-stopped runs.

Suggested change
expect(msg).toContain('agent-prompt --role verify');
expect(msg).toContain('never a hand-rolled agent');
expect(msg).toContain('compose floor');
expect(msg).toContain('Do NOT re-verify findings already');
expect(msg).toContain('agent-prompt --role verify');
expect(msg).toContain('never a hand-rolled agent');
expect(msg).toContain('compose floor');
expect(msg).toContain('Do NOT re-verify findings already');
expect(msg).toContain('stop waiting on any verifier batch still out');
expect(msg).toContain('invent a fresh re-verification pass');
中文说明

[建议] round-cap 拒绝消息新增的两条有界收尾子句目前只钉在了 reverseAuditBudgetMessage(deadline.test.ts:481-482)上,没有钉在这个 round-cap 拒绝消息的位置——本 diff 在 deadline.test.ts 新增的注释声称 "both pin both",但那个对称的 round-cap 一侧并不存在。— 失败场景:经探针验证的变异——仅从 agent-prompt.ts 的 round-cap 拒绝消息中删除 stop waiting on any verifier batch still outinvent a fresh re-verification pass 两条子句,整个套件仍为绿色(agent-prompt.test.ts 13 通过、deadline.test.ts 43 通过、SKILL.test.ts 7 通过),恰好在大 diff 触发上限停止的运行时悄悄放松了有界收尾的终止契约。

— qwen3.8-max via Qwen Code /review (v0.21.9)

Comment on lines +3483 to +3486

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Round-cap refusal message's wait-bound clauses not pinned — Failure scenario: A reword that drops either clause from the round-cap refusal message would pass the test suite, silently loosening the termination contract for the cap gate.

Suggested change
expect(msg).toContain('agent-prompt --role verify');
expect(msg).toContain('never a hand-rolled agent');
expect(msg).toContain('compose floor');
expect(msg).toContain('Do NOT re-verify findings already');
expect(msg).toContain('stop waiting on any verifier batch still out');
expect(msg).toContain('invent a fresh re-verification pass');
中文说明

round-cap 拒绝消息的 wait-bound 从句未被测试固定——失败场景:删除 round-cap 拒绝消息中任何一个从句的改写将不会导致测试失败,从而静默地放宽了 cap 门的终止契约。建议在 agent-prompt.test.ts 中为 round-cap 拒绝消息添加 stop waiting on any verifier batch still outinvent a fresh re-verification pass 的断言,与 deadline.test.ts 中的模式一致。

— deepseek-v4-flash via Qwen Code /review (v0.21.8)

// The wait-bound and no-fresh-pass clauses too — the budget message's
// test pins the same two for the sibling refusal; one bounded-tail
// protocol, both pin both.
expect(msg).toContain('stop waiting on any verifier batch still out');
expect(msg).toContain('invent a fresh re-verification pass');
// The marker is on disk so compose-review caps without the relay.
expect(readBudgetStop(plan)?.cause).toBe('round-cap');
expect(readBudgetStop(plan)?.cap).toBe(3);
Expand Down Expand Up @@ -3592,6 +3609,165 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
expect(readRoundStamps(plan)).toHaveLength(stampsBefore);
});

it('huge cap: a converged past-cap round exits 5, not the cap — convergence outranks it', () => {
// The ordering the PR documents four times (the convergence check runs
// BEFORE the round-cap gate) with no test pin: hoisting the cap check
// above it survives the whole suite. Round 5 is past the cap of 3, but
// its schedule has converged (every chunk twice-dry, odd round → all
// skipped), so it must exit 5 CONVERGED with NO marker — not exit 4 at
// the cap. History that lands convergence on an odd past-cap round: 13/14
// dry in rounds 1-2 (retire at 3), 15 whiffs round 1 then goes dry in
// 2-3, so round 3 (odd) builds only 15 and nothing converges before 5.
writeFileSync(
plan,
JSON.stringify({ ...PLAN, budget: { reverseAuditRounds: 3 } }),
);
const old = new Date(2020, 0, 1);
utimesSync(plan, old, old);
runRound(1);
auditorTranscript(recordOf(1, 13), DRY);
auditorTranscript(recordOf(1, 14), DRY);
auditorTranscript(recordOf(1, 15), WHIFF, { calls: 0 });
answerRound(2, { 13: DRY, 14: DRY, 15: DRY });
answerRound(3, { 15: DRY }); // 13,14 retired (odd → skipped); only 15 built
expect(keysOf(3)).toHaveLength(1);

const out = runRound(5); // 5 > cap 3, but the schedule has converged
expect(process.exitCode).toBe(5);
expect(out).toBe('');
const msg = (writeStderrLine as unknown as Mock).mock.calls
.map((c) => c[0])
.join('\n');
expect(msg).toContain('CONVERGED');
// Convergence outranks the cap: no round-cap refusal, no marker written.
expect(readBudgetStop(plan)).toBeNull();
});

it('huge cap: a CONVERGED exit clears a stale same-run round-cap marker', () => {
// Retry-after-refusal: round 4 (even) is refused at the cap — every
// retired chunk is DUE a cold check, so the schedule is not converged and
// 4 > 3 refuses, writing the marker. The orchestrator then asks for round
// 5, which converges. Nothing else unlinks budget-stop.json, so without
// the converged-branch clear the stale marker caps a verdict that
// legitimately converged.
writeFileSync(
plan,
JSON.stringify({ ...PLAN, budget: { reverseAuditRounds: 3 } }),
);
const old = new Date(2020, 0, 1);
utimesSync(plan, old, old);
runRound(1);
auditorTranscript(recordOf(1, 13), DRY);
auditorTranscript(recordOf(1, 14), DRY);
auditorTranscript(recordOf(1, 15), WHIFF, { calls: 0 });
answerRound(2, { 13: DRY, 14: DRY, 15: DRY });
answerRound(3, { 15: DRY });

runRound(4); // even → retired chunks due cold checks → not converged → cap refuses
expect(process.exitCode).toBe(4);
expect(readBudgetStop(plan)?.cause).toBe('round-cap');

process.exitCode = undefined;
const out = runRound(5); // odd → all skipped → converged
expect(process.exitCode).toBe(5);
expect(out).toBe('');
const msg = (writeStderrLine as unknown as Mock).mock.calls
.map((c) => c[0])
.join('\n');
expect(msg).toContain('CONVERGED');
// The marker channel is closed AND the relay channel is recalled: the
// refusal instructed the orchestrator to add the stop entry to
// unreviewedDimensions, and nothing but this sentence removes it once
// the marker (and with it compose-review's dedup splice) is gone.
expect(msg).toContain('remove it now — this convergence supersedes');
expect(readBudgetStop(plan)).toBeNull(); // the stale marker is cleared
});

it('huge cap: a CONVERGED exit clears a stale same-run time-budget marker too', () => {
// The clear is cause-blind, but both sibling clear tests produce their
// marker via the round-cap gate — a cause-conditional clear
// (`if (readBudgetStop(p)?.cause === 'round-cap') clearBudgetStop(p)`)
// passes them both and leaves a time-budget marker capping a verdict
// the audit legitimately converged. Cap 5 so even round 4 reaches the
// TIME gate instead of the cap gate: cold checks due → not converged →
// admitted at the cap, refused at the near deadline. Round 5 then
// converges and must clear the time-budget marker the same way.
writeFileSync(
plan,
JSON.stringify({ ...PLAN, budget: { reverseAuditRounds: 5 } }),
);
const old = new Date(2020, 0, 1);
utimesSync(plan, old, old);
runRound(1);
auditorTranscript(recordOf(1, 13), DRY);
auditorTranscript(recordOf(1, 14), DRY);
auditorTranscript(recordOf(1, 15), WHIFF, { calls: 0 });
answerRound(2, { 13: DRY, 14: DRY, 15: DRY });
answerRound(3, { 15: DRY });

process.env[DEADLINE_ENV] = String(Math.floor(Date.now() / 1000) + 60);
runRound(4); // even → not converged → 4 <= cap 5 → refused at the time gate
expect(process.exitCode).toBe(4);
expect(readBudgetStop(plan)?.entry).toBe(
'reverse audit — stopped before round 4 by the review time budget',
);

process.exitCode = undefined;
const out = runRound(5); // odd → all skipped → converged, before any gate
expect(process.exitCode).toBe(5);
expect(out).toBe('');
const msg = (writeStderrLine as unknown as Mock).mock.calls
.map((c) => c[0])
.join('\n');
expect(msg).toContain('CONVERGED');
expect(readBudgetStop(plan)).toBeNull(); // the stale time-budget marker is cleared
});

it('huge cap: a converged --chunk retry clears the stale cap marker too', () => {
// The --chunk gate threads the same convergence-first path with its own
// `args.plan`, but only the --all-chunks site's marker clear is pinned
// above: a converged per-chunk retry after a cap refusal must exit 5
// CONVERGED and clear the stale marker exactly like it, not exit 4 at
// the cap (the ordering) and not leave the marker capping a verdict the
// audit legitimately converged (the clear). Same retry-after-refusal
// history as the --all-chunks test.
writeFileSync(
plan,
JSON.stringify({ ...PLAN, budget: { reverseAuditRounds: 3 } }),
);
const old = new Date(2020, 0, 1);
utimesSync(plan, old, old);
runRound(1);
auditorTranscript(recordOf(1, 13), DRY);
auditorTranscript(recordOf(1, 14), DRY);
auditorTranscript(recordOf(1, 15), WHIFF, { calls: 0 });
answerRound(2, { 13: DRY, 14: DRY, 15: DRY });
answerRound(3, { 15: DRY });

runRound(4); // even → retired chunks due cold checks → cap refuses
expect(process.exitCode).toBe(4);
expect(readBudgetStop(plan)?.cause).toBe('round-cap');

process.exitCode = undefined;
(writeStdoutLine as unknown as Mock).mockClear();
(writeStderrLine as unknown as Mock).mockClear();
(agentPromptCommand.handler as (a: unknown) => void)({
plan,
role: 'reverse-audit',
findings,
chunk: 13,
round: 5,
});
expect(process.exitCode).toBe(5);
expect((writeStdoutLine as unknown as Mock).mock.calls).toHaveLength(0);
expect(keysOf(5)).toHaveLength(0);
const msg = (writeStderrLine as unknown as Mock).mock.calls
.map((c) => c[0])
.join('\n');
expect(msg).toContain('CONVERGED');
expect(readBudgetStop(plan)).toBeNull(); // the stale marker is cleared
});

it('a cold-check-only round is still built, admitted and stamped', () => {
answerRound(1, { 13: DRY, 14: DRY, 15: DRY });
answerRound(2, { 13: DRY, 14: DRY, 15: DRY });
Expand Down
33 changes: 27 additions & 6 deletions packages/cli/src/commands/review/agent-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { dirname, join, resolve } from 'node:path';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { launchToolBudget, reverseAuditRoundCap } from './lib/budget.js';
import {
clearBudgetStop,
expectedRoundSeconds,
readRoundStamps,
reverseAuditBudgetExhausted,
Expand Down Expand Up @@ -1885,8 +1886,14 @@ function admitReverseAuditRound(
`not rebuild or retry. A marker has been recorded and compose-review ` +
`will cap the verdict; still add \`${roundCapStopEntry(cap)}\` to ` +
`unreviewedDimensions so the terminal report agrees. If the cap ` +
`round reported findings whose verdicts have not landed, launch ` +
`their verifiers alone and wait, then proceed to Step 6.`,
`round reported findings whose verdicts have not landed, verify them ` +
`ONLY through \`agent-prompt --role verify\` (never a hand-rolled ` +
`agent) — it is gated on the compose floor and will refuse once too ` +
`little time remains; when the deadline is within that floor, stop ` +
`waiting on any verifier batch still out and compose with the tags ` +
Comment on lines +1892 to +1893

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The bounded-tail protocol this PR mirrors into the ROUND CAP message is duplicated in reverseAuditBudgetMessage (lib/deadline.ts:631-660) and the two copies already diverge: a grep across packages/cli/src for the wait-bound clause ("stop waiting on any verifier batch still out") and "invent a fresh re-verification pass" matches exactly one location — this message. The time-budget twin carries neither, even though SKILL.md's budget-stop bullet (line 675) documents both rules for the budget-stop tail and the round-cap bullet this PR rewrites claims parity ("under the same bounded tail as the budget stop below"). Both copies are independently test-pinned (deadline.test.ts:473-477; agent-prompt.test.ts:3479-3486) and neither pins the divergent clauses, so green tests lock the drift in. — Failure scenario: a time-budget stop (the common CI stop mode — the one the measured #8687 kill-before-compose incident ran) hands the orchestrator a stderr message that says verify-then-compose but never says to stop waiting on an already-admitted verifier batch once the deadline enters the compose floor; a verifier admitted above the floor keeps running past it, the orchestrator waits, the wall hits before compose, and confirmed findings never post — the exact harm the wait-bound rule exists to prevent. Suggested fix: extend reverseAuditBudgetMessage's tail with the same two clauses and pin them in its deadline.test.ts sibling test, or hoist the shared tail-protocol sentences into one constant used by both refusals.

中文说明

[建议] 本 PR 镜像进 ROUND CAP 消息的有界收尾协议,在 reverseAuditBudgetMessagelib/deadline.ts:631-660)里有一份副本,且两份已经分叉:在 packages/cli/src 全量 grep 等待上限子句("stop waiting on any verifier batch still out")与 "invent a fresh re-verification pass",只命中一处——就是这条消息。时间预算的孪生消息两条都没有,而 SKILL.md 的 budget-stop 条目(第 675 行)明确要求 budget-stop 收尾同时遵守这两条规则,本 PR 重写的 round-cap 条目又声称两者对等("under the same bounded tail as the budget stop below")。两份副本各自被测试钉住(deadline.test.ts:473-477agent-prompt.test.ts:3479-3486),且都没有钉分叉的子句,于是绿色测试把漂移锁死了。——故障场景:时间预算停止(CI 中最常见的停止方式——被实测记录的 #8687「compose 前被杀」事故就发生在这条路径)交给编排器的 stderr 消息只说 verify-then-compose,却没有说当 deadline 进入 compose 预留区后要停止等待已放行的验证器批次;一个在预留线之上放行的验证器继续运行越过预留线,编排器一直等,时间墙在 compose 之前落下,已确认的发现一条都没发出去——这正是等待上限规则要防止的危害。建议修复:给 reverseAuditBudgetMessage 的尾部补上同样的两条子句,并在它的 deadline.test.ts 孪生测试里钉住;或把共享的收尾协议语句提取为一个常量,供两处拒绝消息共用。

— qwen3.8-max via Qwen Code /review (v0.21.8)

`in hand. Do NOT re-verify findings already confirmed in earlier ` +
`rounds, and do NOT invent a fresh re-verification pass. Then ` +
`proceed to Step 6.`,
);
process.exitCode = 4;
return false;
Expand All @@ -1909,13 +1916,27 @@ function admitReverseAuditRound(
* twice over, so another round would audit nothing the history has not
* already answered. Not an error and not a gap — no record, no stamp, no
* disclosure owed; a round that builds nothing was never admitted.
*
* Clears any same-run stop marker first: a converged exit can follow an
* over-cap round the gate already refused (round 4 refused under cap 3,
Comment on lines +1920 to +1921

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The CONVERGED-exit clear closes the marker channel but leaves the relay channel open: nothing tells the orchestrator to drop the round-cap unreviewedDimensions entry the earlier refusal explicitly instructed it to add, and compose-review's phrase-dedup splice (compose-review.ts:423-443) runs only while a marker is on disk. — Failure scenario: cap 3; round 4 is refused at the cap and stderr instructs "still add reverse audit — did not converge within the reverse-audit round cap of 3 to unreviewedDimensions"; the orchestrator records the entry (compliant), then asks for round 5 anyway, which exits CONVERGED. The marker is cleared, but the CONVERGED message only says "no unreviewedDimensions entry is owed" — it never says to remove one already added (no removal instruction exists anywhere, including SKILL.md's CONVERGED bullet). At Step 6 the stale relayed entry survives the missing splice, caps the legitimately-converged verdict at COMMENT via unreviewed-dimension (compose-review.ts:862-873), and the public body of an audit that converged says "did not converge within the round cap". Pre-PR this was masked because the surviving marker caused the relayed copy to be spliced out — the clear is what makes this false cap newly reachable. Suggested fix: extend the CONVERGED stderr (and the matching SKILL.md bullet) with e.g. "if an earlier round-cap or budget refusal told you to add its stop entry to unreviewedDimensions, remove it now — this convergence supersedes it"; alternatively have compose-review splice both stop phrases whenever the schedule converged.

中文说明

[建议] CONVERGED 退出时的清标记只关闭了标记通道, relay 通道仍然敞着:没有任何提示让编排器丢弃此前拒绝消息明确要求它添加的 round-cap unreviewedDimensions 条目,而 compose-review 的短语去重拼接(compose-review.ts:423-443)只在标记文件存在时才运行。——故障场景:cap 3;第 4 轮在 cap 处被拒,stderr 指示「仍要把 reverse audit — did not converge within the reverse-audit round cap of 3 加入 unreviewedDimensions」;编排器照做记下该条目(合规行为),然后照样请求第 5 轮,第 5 轮以 CONVERGED 退出。标记被清掉,但 CONVERGED 消息只说「不欠 unreviewedDimensions 条目」——从不说要移除已添加的条目(包括 SKILL.md 的 CONVERGED 条目在内,任何地方都没有移除指示)。到 Step 6 时,残留的 relay 条目因缺少拼接去重而保留下来,通过 unreviewed-dimensioncompose-review.ts:862-873)把合法收敛的裁决封顶为 COMMENT,一份已收敛审计的公开正文却写着「未在轮数上限内收敛」。PR 之前这一问题被掩盖,因为存活的标记会把 relay 副本拼接掉——正是清标记让这一错误封顶变得可触达。建议修复:在 CONVERGED stderr(以及对应的 SKILL.md 条目)中补充类似「如果此前某次 round-cap 或 budget 拒绝让你把停止条目加入了 unreviewedDimensions,现在移除它——本次收敛取代那次拒绝」的语句;或者让 compose-review 在调度收敛时无条件拼接两种停止短语。

— qwen3.8-max via Qwen Code /review (v0.21.8)

* then round 5's schedule is converged — the convergence check runs before
* the cap gate), and that stale round-cap marker would otherwise cap a
* verdict the audit legitimately converged. The message also recalls the
* relay channel: the earlier refusal told the orchestrator to add its stop
* entry to unreviewedDimensions, and once the marker is gone the
* compose-review splice that dedups it no longer runs — only this
* instruction removes it.
*/
function refuseConverged(): void {
function refuseConverged(planPath: string): void {
clearBudgetStop(planPath);
Comment on lines +1930 to +1931

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The cause-blind clear on a CONVERGED exit is only tested against round-cap markers — both new clear tests produce their marker via a cap-3 round-4 refusal, so a future cause-conditional clear (if (readBudgetStop(p)?.cause === 'round-cap') clearBudgetStop(p)) would pass the whole suite. — Failure scenario: under cap 5, an even round 4 refused by the time gate writes a time-budget marker, then odd round 5 converges; probe-verified, the mutation survives all 222 agent-prompt.test.ts tests, and the stale time-budget marker then caps a legitimately converged verdict at COMMENT with a false "stopped before round 4 by the review time budget" disclosure.

Fix: add a sibling test beside the two existing clear tests — same retirement history under cap 5 with a near deadline so even round 4 is refused at the time gate (assert the time-budget marker is written), then run odd round 5 and assert exit 5, CONVERGED on stderr, and readBudgetStop(plan) null.

中文说明

[建议] CONVERGED 退出时的无差别清标记目前只有 round-cap 标记的测试覆盖——两个新的清标记测试都通过 cap-3 第 4 轮拒绝来产生标记,因此未来若改成按 cause 条件清标记(if (readBudgetStop(p)?.cause === 'round-cap') clearBudgetStop(p)),整个测试套件仍会全部通过。失败场景:在 cap 5 下,偶数第 4 轮被时间门拒绝、写入 time-budget 标记,随后奇数第 5 轮收敛;经探针验证,该变异下 agent-prompt.test.ts 全部 222 个测试仍然通过,残留的 time-budget 标记会把合法收敛的裁决封顶为 COMMENT,并附带一条不实的 "stopped before round 4 by the review time budget" 披露。修复:在现有两个清标记测试旁补一个同族测试——同样的退休历史、cap 5、临近 deadline,使偶数第 4 轮被时间门拒绝(断言写入了 time-budget 标记),再运行奇数第 5 轮,断言 exit 5、stderr 出现 CONVERGEDreadBudgetStop(plan) 为 null。

— qwen3.8-max via Qwen Code /review (v0.21.8)

writeStderrLine(
'CONVERGED: every chunk holds two consecutive substantive dry audits; ' +
'the reverse audit has converged — stop the loop and proceed to ' +
'Step 6. This is a clean convergence, not a gap: no ' +
'unreviewedDimensions entry is owed.',
'unreviewedDimensions entry is owed. If an earlier round-cap or ' +
'budget refusal told you to add its stop entry to ' +
'unreviewedDimensions, remove it now — this convergence supersedes ' +
'it.',
);
process.exitCode = 5;
}
Expand Down Expand Up @@ -1968,7 +1989,7 @@ function runAllChunks(
}

if (schedule !== null && schedule.converged) {
refuseConverged();
refuseConverged(planPath);
return;
}

Expand Down Expand Up @@ -2470,7 +2491,7 @@ function runAgentPrompt(args: AgentPromptArgs): void {
schedule = null;
}
if (schedule !== null && schedule.converged) {
refuseConverged();
refuseConverged(args.plan);
return;
}
}
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/review/compose-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,24 @@ describe('composeReview — event caps (round-7 Critical #2: caps must reach eve
expect(r.remediation.join(' ')).not.toContain('reverse audit:');
});

it('a round-cap stop does NOT suppress the not-built gap — its rebuild is admitted', () => {
// R4-9: the reverseByDesign exemption is time-budget-ONLY. A round-cap
// marker with zero reverse-audit records must not suppress the not-built
// gap the way a time-budget stop does: the cap gate refuses only
// `round > cap`, so the gap's FIX (rebuild `--round 1`) is admitted, and
// a local run has no deadline to refuse it at all. Reading the marker
// cause-blind would silently drop both the gap and its rebuild
// remediation for a run that audited nothing.
const plan = coveredPlan([]); // no reverse-audit ran — the not-built shape
writeRoundCapStop(plan, 3, 4);
const r = composeReview({ planPath: plan, env: ENV, modelId: MODEL });
// The round-cap marker still discloses and caps the verdict…
expect(r.event).toBe('COMMENT');
expect(r.body).toContain('reverse-audit round cap of 3');
// …but the not-built gap and its rebuild remediation are still owed.
expect(r.remediation.join(' ')).toContain('reverse audit:');
});

it('renders the budget stop bilingually on a Han-description PR', () => {
// Every sibling structural disclosure carries a zh pair; the budget stop
// used to ride the caller-prose path and posted English into both halves.
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/src/commands/review/lib/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const SWEEP_FLOOR = 25;
/**
* The reverse-audit loop's full round cap (SKILL.md Step 5's "stop at the
* plan's `reverseAuditRounds` cap"). The normal value; a huge diff gets
* `HUGE_REVERSE_AUDIT_ROUNDS` instead. `retirement.ts` re-exports it.
* `HUGE_REVERSE_AUDIT_ROUNDS` instead. `compose-review` imports it directly.
*/
export const MAX_REVERSE_AUDIT_ROUNDS = 5;

Expand Down Expand Up @@ -232,10 +232,13 @@ export function reviewBudget(input: BudgetInput): ReviewBudget {

/**
* The reverse-audit round cap a plan's budget carries, for every reader
* that enforces or narrates it (the admission gate, the retirement
* scheduler, the cold-check note). A plan without the field — an older
* CLI — or a garbled value reads as the full cap: an old plan errs toward
* more auditing, never less, exactly like every other budget fallback.
* that enforces or narrates it (the admission gate and the cold-check
* note, both in `agent-prompt`; the retirement scheduler deliberately
* ignores the cap — whether a scheduled cold check is allowed is the note
* composer's question, not the schedule's). A plan without the field — an
* older CLI — or a garbled value reads as the full cap: an old plan errs
* toward more auditing, never less, exactly like every other budget
* fallback.
*
* The accepted range is floored at `HUGE_REVERSE_AUDIT_ROUNDS`, the
* smallest cap the CLI ever writes. A value of one or two is out of band
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/commands/review/lib/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1428,7 +1428,7 @@ export function verificationGaps(
(k) => k === 'reverse-audit' || k.startsWith('reverse-audit--'),
);
const reverse = bestDelivery(reverseKeys);
// A budget-stop marker means the round builder itself refused the reverse
// A TIME-budget stop marker means the round builder refused the reverse
// audit on the run's time budget. Exactly ONE gap shape is then by design:
// `not-built` — the refusal writes no record, so an audit with no records
// is the audit the gate stopped, and the gap's FIX (rebuild the round)
Expand All @@ -1441,7 +1441,15 @@ export function verificationGaps(
// hand-written round-1 launch is exactly as undelivered when round 3 later
// hits the budget, and suppressing it would let "stopped before round 3"
// imply the rounds that did run were faithful.
const budgetStopped = readBudgetStop(planPath) !== null;
//
// Only the time-budget cause earns this exemption. A ROUND-CAP stop does
// NOT: the cap gate refuses only `round > cap`, so the not-built gap's FIX
// (rebuild `--round 1`) is admitted, and a local run has no deadline to
// refuse it at all — the monotone-refusal premise fails twice. So a
// round-cap marker leaves the not-built gap and its rebuild remediation
// owed, exactly as if no marker were present.
const stop = readBudgetStop(planPath);
const budgetStopped = stop !== null && stop.cause !== 'round-cap';
const reverseByDesign = budgetStopped && reverse === 'not-built';
// A repairable reverse-audit gap only at high: medium is complete without it.
const reverseGap = !balancedMedium && !reverseByDesign && reverse !== 'ok';
Expand Down
Loading
Loading