Skip to content

Commit 0d57a4d

Browse files
committed
feat(review): gate prompt sections by review cycle
1 parent 3444621 commit 0d57a4d

4 files changed

Lines changed: 293 additions & 27 deletions

File tree

‎.github/workflows/tests.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ jobs:
1818

1919
- name: Review cycle counter
2020
run: tests/review-cycle-test.sh
21+
22+
- name: Cycle-gated prompt rendering
23+
run: tests/render-prompt-test.sh

‎docs/claude-pr-review-prompt.md‎

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,40 @@ You are an expert code reviewer embedded in a GitHub Actions workflow. Your job
44

55
This prompt includes:
66
- **REVIEW CYCLE** — which review iteration this is (1 = first review, 2+ = re-review after changes)
7-
- **Prior Review Comments** — existing inline comment threads from previous reviews, including author responses. If this is cycle 1, there will be no prior comments — skip straight to reviewing the code.
7+
- **Prior Review Comments** — existing inline comment threads from previous reviews, including author responses
8+
<!-- cycle>=2 -->
9+
- **`<incremental_diff>`** — the changes pushed since your last review. This is your review surface for this cycle.
10+
<!-- /cycle -->
11+
12+
This prompt has already been narrowed to the current review cycle by the workflow. Everything below applies as written — there is no cycle ladder left for you to interpret, and nothing here is conditional on the cycle number.
813

914
## Review Process
1015

11-
1. **Understand the PR** — read the title, description, and linked issues to understand intent
12-
2. **Read prior review threads** — if cycle 2+, read the prior review comments included above to understand what feedback was already given and how the author responded
13-
3. **Inspect the diff** — use `gh pr diff` to see what changed
14-
4. **Read affected files** — use `Read` to get full context around changed code
15-
5. **Post feedback** — use inline comments for specific issues, and a summary comment only when requesting changes
16+
Work in this order:
1617

17-
## Handling Prior Feedback (cycle 2+ only)
18+
- **Understand the PR** — read the title, description, and linked issues to understand intent
19+
<!-- cycle==1 -->
20+
- **Inspect the diff** — use `gh pr diff` to see what changed
21+
<!-- /cycle -->
22+
<!-- cycle>=2 -->
23+
- **Read prior review threads** — understand what feedback was already given and how the author responded
24+
- **Inspect only the new changes** — the `<incremental_diff>` block above holds everything pushed since your last review. That is your review surface. Do not re-review code you already passed on, and do not mine unchanged hunks for findings you missed the first time. Fall back to `gh pr diff` only if that block reports the incremental diff is unavailable.
25+
<!-- /cycle -->
26+
- **Read affected files** — use `Read` to get full context around changed code
27+
- **Post feedback** — inline comments for specific issues; a summary comment only when requesting changes
1828

19-
Skip this section entirely on cycle 1.
29+
<!-- cycle>=2 -->
30+
## Handling Prior Feedback
2031

21-
- **Blocking issues stay blocking.** If a prior review flagged a blocking issue, it remains blocking until it is fixed in the code. An author reply alone does not resolve a blocking issue — the code must change. If the author's reply reveals that your original assessment was wrong (e.g., you misread the code), you may drop it.
32+
- **Blocking issues stay blocking.** If a prior review flagged a blocking issue, it remains blocking until it is fixed in the code. An author reply alone does not resolve a blocking issue — the code must change. If the author's reply reveals that your original assessment was wrong (e.g. you misread the code), you may drop it.
2233
- **Do not re-raise resolved issues.** If prior blocking feedback was addressed in new commits, move on.
2334
- **Do not re-raise nits the author declined.** If the author pushed back on a non-blocking suggestion with a reasonable explanation, respect their judgment and do not repeat it.
24-
- If all prior blocking issues are resolved, update your review status accordingly (approve or request changes based on new findings only).
25-
26-
## Review Cycle Awareness
27-
28-
- **Cycle 1–2**: Full review. Flag blocking issues and nits.
29-
- **Cycle 3–4**: Focus on blocking issues. Only leave nits if they are genuinely important.
30-
- **Cycle 5+**: Blocking issues only. Do not leave any nits.
31-
32-
The goal is to converge toward merge, not to find new things to complain about in each round.
35+
- **Do not charge the author for churn you caused.** Code comments and docstrings that drifted out of date because the author was addressing your earlier feedback are not new findings. Leave them alone.
36+
- If all prior blocking issues are resolved, update your review status based on new findings only.
37+
<!-- /cycle -->
3338

3439
## Review Criteria
3540

36-
### Code Quality
37-
- Follows existing style and conventions in the repo
38-
- No commented-out code or debug artifacts
39-
- Meaningful, consistent naming
40-
- DRY — no unnecessary duplication
41-
4241
### Correctness
4342
- No obvious bugs or off-by-one errors
4443
- Edge cases are handled
@@ -60,10 +59,21 @@ The goal is to converge toward merge, not to find new things to complain about i
6059
- No obvious N+1 queries or unnecessary loops
6160
- No blocking calls in hot paths
6261

62+
<!-- cycle<=2 -->
63+
### Code Quality
64+
- Follows existing style and conventions in the repo
65+
- No commented-out code or debug artifacts
66+
- Meaningful, consistent naming
67+
- DRY — no unnecessary duplication
68+
<!-- /cycle -->
69+
70+
<!-- cycle==1 -->
6371
### Documentation
6472
- Public APIs and functions are documented
6573
- README or docs updated if user-facing behavior changed
74+
<!-- /cycle -->
6675

76+
<!-- cycle<=2 -->
6777
## Severity Classification
6878

6979
Classify all findings into one of three levels:
@@ -77,13 +87,35 @@ Classify all findings into one of three levels:
7787
- **No issues found** → approve with `gh pr review --approve`. No summary comment.
7888
- **Only nits/super nits** → approve with `gh pr review --approve`. Leave inline comments. No summary comment.
7989
- **Blocking issues found** → request changes with `gh pr review --request-changes`. Leave inline comments. Leave a summary comment (format below).
90+
<!-- /cycle -->
91+
92+
<!-- cycle>=3 -->
93+
## What to Report
94+
95+
Report **blocking issues only**: security vulnerabilities, data loss, broken builds, correctness bugs, logic errors, missing error handling on critical paths, race conditions.
96+
97+
Everything else is out of scope for this cycle. That includes code quality, naming, duplication, style, documentation and comment gaps, test-shape preferences, and every other non-blocking observation. Do not post it — not as an inline comment, not as an observation or heads-up or FYI, and not as a parenthetical tacked onto a blocking comment.
98+
99+
If the only things you found are non-blocking, approve with no inline comments at all.
100+
101+
The author has already been through several rounds on this PR. Converging is worth more than completeness. A finding you could have raised on cycle 1 and did not is not worth raising now.
102+
103+
## Decision Framework
104+
105+
- **No blocking issues** → approve with `gh pr review --approve`. No inline comments. No summary comment.
106+
- **Blocking issues found** → request changes with `gh pr review --request-changes`. Leave inline comments for each. Leave a summary comment (format below).
107+
<!-- /cycle -->
80108

81109
## Output Rules
82110

83111
- **Do not be chatty.** No filler, no praise, no "looks good overall" preamble.
84112
- **Do not feel compelled to find problems.** If the code is fine, approve it.
85113
- **Do not nitpick.** Skip style issues that a linter should catch.
114+
<!-- cycle<=2 -->
86115
- Nit and super nit comments MUST always include `(not blocking)`.
116+
<!-- /cycle -->
117+
- Be direct and specific — cite file paths and line numbers
118+
- Be constructive — explain *why* something is a problem and suggest a fix
87119
- Only leave a summary comment when requesting changes:
88120

89121
```
@@ -95,6 +127,3 @@ Classify all findings into one of three levels:
95127
### Action Required
96128
[Specific changes needed before this can merge]
97129
```
98-
99-
- Be direct and specific — cite file paths and line numbers
100-
- Be constructive — explain *why* something is a problem and suggest a fix

‎scripts/render-prompt.sh‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Renders the review prompt for one specific review cycle, on stdout, from the prompt on
4+
# stdin. Blocks wrapped in cycle markers are kept or dropped here, before the prompt ever
5+
# reaches the model:
6+
#
7+
# <!-- cycle==1 --> ... <!-- /cycle --> kept only on cycle 1
8+
# <!-- cycle<=2 --> ... <!-- /cycle --> kept on cycles 1-2
9+
# <!-- cycle>=3 --> ... <!-- /cycle --> kept on cycle 3 and later
10+
#
11+
# Why this is a script and not three sentences in the prompt: the prompt used to carry a
12+
# "Review Cycle Awareness" ladder telling the model to taper nits as cycles climbed, and
13+
# measurement showed it was ignored outright. Across cycle 6+ reviews the ladder's rule was
14+
# "blocking issues only, do not leave any nits" and the actual output was 31 comments, 0 of
15+
# them blocking, 100% nits. Advisory self-restraint loses to a 50-line criteria checklist
16+
# that invites finding things. A block that is never sent cannot be ignored.
17+
#
18+
# Markers must sit alone on their own line and must not nest. Malformed markers are a hard
19+
# error (exit 2) rather than a silent mis-render; the caller falls back to the full prompt.
20+
21+
set -euo pipefail
22+
23+
if [ $# -ne 1 ]; then
24+
echo "usage: render-prompt.sh <review-cycle> < prompt.md" >&2
25+
exit 2
26+
fi
27+
28+
case $1 in
29+
'' | *[!0-9]*)
30+
echo "render-prompt.sh: review cycle must be a positive integer, got '$1'" >&2
31+
exit 2
32+
;;
33+
esac
34+
35+
if [ "$1" -lt 1 ]; then
36+
echo "render-prompt.sh: review cycle must be >= 1, got '$1'" >&2
37+
exit 2
38+
fi
39+
40+
# `cat -s` collapses the run of blank lines a dropped block leaves behind. pipefail keeps
41+
# awk's exit status, so a malformed marker still fails the pipeline.
42+
awk -v C="$1" '
43+
BEGIN { depth = 0; keep = 1; err = "" }
44+
45+
/^<!-- cycle(<=|>=|==)[0-9]+ -->$/ {
46+
if (depth) { err = "nested cycle block at line " NR; exit 2 }
47+
spec = $0
48+
sub(/^<!-- cycle/, "", spec)
49+
sub(/ -->$/, "", spec)
50+
op = substr(spec, 1, 2)
51+
n = substr(spec, 3) + 0
52+
depth = 1
53+
if (op == "<=") keep = (C <= n)
54+
else if (op == ">=") keep = (C >= n)
55+
else keep = (C == n)
56+
next
57+
}
58+
59+
/^<!-- \/cycle -->$/ {
60+
if (!depth) { err = "unmatched <!-- /cycle --> at line " NR; exit 2 }
61+
depth = 0
62+
keep = 1
63+
next
64+
}
65+
66+
{ if (!depth || keep) print }
67+
68+
END {
69+
if (err != "") { printf "render-prompt.sh: %s\n", err > "/dev/stderr"; exit 2 }
70+
if (depth) { printf "render-prompt.sh: unclosed cycle block\n" > "/dev/stderr"; exit 2 }
71+
}
72+
' | cat -s

‎tests/render-prompt-test.sh‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Guards the mechanical cycle gating in scripts/render-prompt.sh, and asserts the shipped
4+
# prompt actually renders the way each cycle needs it to. These run against the real
5+
# docs/claude-pr-review-prompt.md, not a fixture, so editing the prompt into a state where
6+
# a late cycle can emit nits again fails the suite.
7+
#
8+
# Regression this exists for: the prompt used to carry a "Review Cycle Awareness" ladder
9+
# ("Cycle 5+: blocking issues only. Do not leave any nits.") and the model ignored it. Over
10+
# the cycle 6+ reviews sampled, output was 31 comments, 0 blocking, 100% nits. The gating is
11+
# mechanical now -- the nit taxonomy is not in the cycle 3+ prompt at all -- so these tests
12+
# check for the absence of text rather than trusting a rule to be obeyed.
13+
14+
set -euo pipefail
15+
16+
cd "$(dirname "$0")/.."
17+
18+
RENDER=scripts/render-prompt.sh
19+
PROMPT=docs/claude-pr-review-prompt.md
20+
21+
failures=0
22+
23+
pass() { echo "ok $1"; }
24+
fail() {
25+
echo "FAIL $1"
26+
failures=$((failures + 1))
27+
}
28+
29+
render() { bash "$RENDER" "$1" < "$PROMPT"; }
30+
31+
# has <cycle> <pattern> <description>
32+
has() {
33+
if render "$1" | grep -qF -- "$2"; then pass "cycle $1 keeps: $3"; else fail "cycle $1 is missing: $3"; fi
34+
}
35+
36+
# lacks <cycle> <pattern> <description>
37+
lacks() {
38+
if render "$1" | grep -qF -- "$2"; then fail "cycle $1 still contains: $3"; else pass "cycle $1 drops: $3"; fi
39+
}
40+
41+
echo "-- marker mechanics --"
42+
43+
# Each comparison operator, on both sides of its boundary.
44+
fixture=$(mktemp)
45+
trap 'rm -f "$fixture"' EXIT
46+
cat > "$fixture" <<'EOF'
47+
always
48+
<!-- cycle==1 -->
49+
only-one
50+
<!-- /cycle -->
51+
<!-- cycle<=2 -->
52+
upto-two
53+
<!-- /cycle -->
54+
<!-- cycle>=3 -->
55+
three-plus
56+
<!-- /cycle -->
57+
tail
58+
EOF
59+
60+
# expect_render <cycle> <space separated expected lines>
61+
expect_render() {
62+
local cycle=$1 want=$2 got
63+
got=$(bash "$RENDER" "$cycle" < "$fixture" | tr '\n' ' ' | sed 's/ */ /g;s/ $//')
64+
if [ "$got" = "$want" ]; then
65+
pass "cycle $cycle renders '$want'"
66+
else
67+
fail "cycle $cycle: expected '$want', got '$got'"
68+
fi
69+
}
70+
71+
expect_render 1 "always only-one upto-two tail"
72+
expect_render 2 "always upto-two tail"
73+
expect_render 3 "always three-plus tail"
74+
expect_render 9 "always three-plus tail"
75+
76+
# Markers themselves must never reach the model.
77+
if bash "$RENDER" 1 < "$fixture" | grep -q '<!-- '; then
78+
fail "marker comments leak into the rendered output"
79+
else
80+
pass "marker comments are stripped from the output"
81+
fi
82+
83+
echo "-- input validation --"
84+
85+
# expect_reject <args...> -- the script must exit 2, not silently mis-render
86+
expect_reject() {
87+
local desc=$1; shift
88+
if bash "$RENDER" "$@" < "$fixture" >/dev/null 2>&1; then
89+
fail "accepted invalid input: $desc"
90+
else
91+
pass "rejects $desc"
92+
fi
93+
}
94+
expect_reject "a non-numeric cycle" abc
95+
expect_reject "cycle 0" 0
96+
expect_reject "a negative cycle" -1
97+
expect_reject "no arguments"
98+
99+
# Malformed markers are a hard error so the caller can fall back to the full prompt rather
100+
# than ship a half-stripped one.
101+
for bad in '<!-- cycle>=2 -->\nx\n<!-- cycle>=3 -->\ny\n<!-- /cycle -->' \
102+
'<!-- cycle>=2 -->\nx' \
103+
'x\n<!-- /cycle -->'; do
104+
if printf '%b\n' "$bad" | bash "$RENDER" 2 >/dev/null 2>&1; then
105+
fail "accepted malformed markers: $bad"
106+
else
107+
pass "rejects malformed markers: $bad"
108+
fi
109+
done
110+
111+
echo "-- shipped prompt, cycle 1 (full review) --"
112+
has 1 "### Documentation" "the Documentation criterion"
113+
has 1 "### Code Quality" "the Code Quality criterion"
114+
has 1 "## Severity Classification" "the nit taxonomy"
115+
has 1 'gh pr diff' "the full-diff instruction"
116+
lacks 1 "## Handling Prior Feedback" "prior-feedback handling (nothing to handle yet)"
117+
lacks 1 "incremental_diff" "the incremental diff reference"
118+
119+
echo "-- shipped prompt, cycle 2 (focused review) --"
120+
# Documentation drops at cycle 2+: doc/comment drift was 42% of late-cycle comments, and most
121+
# of it is churn the review loop created by asking the author to change the code underneath.
122+
lacks 2 "### Documentation" "the Documentation criterion"
123+
has 2 "## Severity Classification" "the nit taxonomy (nits are still useful at cycle 2)"
124+
has 2 "## Handling Prior Feedback" "prior-feedback handling"
125+
has 2 "incremental_diff" "the incremental diff reference"
126+
has 2 "Do not charge the author for churn you caused" "the churn guard"
127+
128+
echo "-- shipped prompt, cycle 3+ (blocking only) --"
129+
for c in 3 6 12; do
130+
lacks "$c" "## Severity Classification" "the nit taxonomy"
131+
lacks "$c" "super nit" "the super nit vocabulary"
132+
lacks "$c" "### Documentation" "the Documentation criterion"
133+
lacks "$c" "### Code Quality" "the Code Quality criterion"
134+
has "$c" "Report **blocking issues only**" "the blocking-only instruction"
135+
has "$c" "incremental_diff" "the incremental diff reference"
136+
done
137+
138+
# The single most important assertion in this file: at a late cycle there must be no
139+
# instruction anywhere that tells the model how to format a non-blocking comment. If a
140+
# future edit reintroduces one, the 100%-nits behaviour comes back.
141+
if render 5 | grep -qE 'nit:|\(not blocking\)'; then
142+
fail "cycle 5 prompt still explains how to write a nit"
143+
else
144+
pass "cycle 5 prompt has no nit format to follow"
145+
fi
146+
147+
# Exactly one Decision Framework must survive at every cycle -- two would leave the model
148+
# choosing between contradictory approve/request-changes rules.
149+
for c in 1 2 3 7; do
150+
n=$(render "$c" | grep -c '^## Decision Framework$' || true)
151+
if [ "$n" -eq 1 ]; then
152+
pass "cycle $c has exactly one Decision Framework"
153+
else
154+
fail "cycle $c has $n Decision Framework sections, expected 1"
155+
fi
156+
done
157+
158+
if [ "$failures" -ne 0 ]; then
159+
echo "$failures test(s) failed"
160+
exit 1
161+
fi
162+
echo "all tests passed"

0 commit comments

Comments
 (0)