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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "multi-review",
"description": "Human-gated star review: Claude (the neutral primary) fans a spec/plan/PR out to N independent secondary reviewers (in-harness fable by default — disable with MULTI_REVIEW_FABLE=off — plus cross-vendor codex/gemini), adjudicates their findings, and converges — always ending at a human approval gate.",
"version": "1.32.2",
"version": "1.32.4",
"author": {
"name": "Agrology"
},
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ run. Reset it with a "forget the reviewers" request.
- `.claude-plugin/plugin.json` — plugin manifest
- `scripts/multi-review-star.sh` — star grammar: mode / resolve-set / merge / open-findings / check-converged / gate-summary / witness-gaps / refan-check / release / blind-check / channel-check / compose
- `scripts/multi-review-reviewer.sh` — provider registry: resolve / check / prompt / command / ensure-skill / doctor / verify-vendor / vendor-of-model
- `scripts/multi-review-pr.sh` — PR ingest (via `gh`), `refresh` for a later round, per-round head/merge-base records, content-based anchor remapping, a digest-verified diff window (the writer records the SHA-256 of the section body it composed; readers accept the one section that matches, so neither the PR description nor the review channel can move the window), + the one-neutral-review publish
- `scripts/multi-review-pr.sh` — PR ingest (via `gh`), `refresh` for a later round, per-round head/merge-base records, content-based anchor remapping, a digest-verified diff window (the writer records the SHA-256 of the section body it composed; readers accept the one section that matches, so neither the PR description nor the review channel can move the window; `refresh` re-fetches `## PR description` under the same guard, so it never drifts from the diff and a hand-edited one is left alone), + the one-neutral-review publish
- `scripts/multi-review-scope.sh` — diff-scoped copies for round N≥2: `local-copy` (docs — fence-aware region mapping) and `pr-copy` (PRs — what the author pushed since the last round as a `git diff -W -U10` delta with function context, guarded against rebases and forward merges). Both refuse to emit a scoped copy that comes out no smaller than the artifact it replaces; `local-copy` applies that guard only once the artifact is ≥1 KiB, below which scoping cannot win by construction and the waste is bounded by ~1 KiB. Every path that cannot scope exits 3 with its reason and the round falls back to the full artifact, announced.
- `scripts/multi-review-planlint.sh` — the plan lint: `check <doc> [--repo <root>]`, run before every fan-out
- `scripts/multi-review-core.sh` — marker state, `sections`, `blocks`; `-wait.sh` — bounded per-copy wait;
Expand Down
16 changes: 15 additions & 1 deletion commands/multi-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,21 @@ re-resolve later (a mutable env var could otherwise swap providers mid-review un

`refresh` re-fetches the diff at the current head, records this round's head/merge-base,
captures existing anchors' line text, and replaces `## Diff` while preserving `## Review`
and the manifest.
and the manifest. It also re-fetches the PR body and replaces `## PR description`, so the
description and the diff describe the same revision — from round 2 the two used to drift,
and secondaries spent findings on the disagreement (issue #85). The description is written
under the same digest guard as the diff, so one that was hand-edited since it was last
written no longer matches and is LEFT AS-IS: `refresh` still exits 0 and prints
`PR description not refreshed` on stderr. Relay that line, and reconcile the description
against the PR by hand before seeding the copies — do not fan out a description that
contradicts the diff.
Comment on lines +360 to +364

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 low — Once the primary reconciles the description by hand, as the round-N step instructs, no later refresh can ever write it again: the body matches no record, there is no record-desc to re-arm the guard, and only ingest --fresh re-seeds — so every subsequent round skips and warns, and the description is only as current as the primary's last manual pass. Neither the command doc nor the PR's assumptions say this. — risk: a one-time hand edit silently turns the fix off for the rest of the review, reintroducing the #85 drift the primary now has to close by hand each round — 🤖 multi-review star review (claude-fable-5-1 + claude-opus-5)


**That hand reconcile is one-way.** Your edited body matches no recorded digest, nothing
re-arms the guard, and so
every later round skips it too — the description is then only as current as your last manual
pass, and keeping it aligned with the diff stays your job for the rest of the review. Say so
at the gate. Only `ingest --fresh` re-seeds a recorded description, and it discards every
accumulated finding and response, so it is rarely the right trade mid-review.

**If `refresh` fails with a diff-window message** (`no '## Diff' section … matches a recorded
digest`, or `… ambiguous diff window`), it wrote NOTHING — that is deliberate. The diff window
Expand Down
98 changes: 96 additions & 2 deletions scripts/multi-review-mutation-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -650,10 +650,20 @@ mutations() {

# Same swallow in the other writer: an unreadable description produced a document with the PR
# description missing, and seed then recorded a digest for the truncation.
# Since #85 the description is read ONCE, at compose time, so that line is where an unreadable
# description now fails; the `cat "$dbodyf"` in the write group sits behind it and reads a temp
# this script just wrote (next entry).
mutate 'pr/seed-desc-propagation' 'scripts/multi-review-pr.sh' replace \
'seed: committed a document despite an unreadable description' 'multi-review-pr.test.sh' \
' cat "$descf" || exit 1' \
' cat "$descf"'
' _compose_desc_body "$descf" > "$dbodyf" || { rm -f "$bodyf" "$dbodyf"; die "cannot compose the description body" 1; }' \
' _compose_desc_body "$descf" > "$dbodyf"'

# Deliberately redundant behind the compose guard above: no test can make a temp file this script
# just wrote unreadable. Recorded so that losing the compose guard surfaces here.
mutate 'pr/seed-desc-cat-propagation' 'scripts/multi-review-pr.sh' replace \
'SURVIVES-BY-DESIGN' 'multi-review-pr.test.sh' \
' cat "$dbodyf" || exit 1' \
' cat "$dbodyf"'

# found-but-EMPTY must not look like NOT-FOUND. A blank diff line captured as empty text was read
# as "not in the diff", nothing was recorded, and the anchor later remapped to a STALE line that
Expand Down Expand Up @@ -2882,6 +2892,90 @@ mutations() {
mutate 'pr/record-diff-empty-body' 'scripts/multi-review-pr.sh' delete \
'recorded the digest of an empty body' 'multi-review-pr.test.sh' \
' [[ -s "$bodyf" ]] || die "diff body file is empty: $bodyf — refusing to record the digest of nothing" 1'

# Issue #85: `refresh` re-fetches the PR description under the diff's digest rule. The writer
# records the body it composed (seed and replace-desc), the reader accepts only a body that
# matches, and a hand-edited body is left alone with a reason — the description is the one
# section a primary may legitimately edit, so a clobber is real data loss.
mutate 'pr/desc-record-seeded' 'scripts/multi-review-pr.sh' delete \
'seed: no description digest in the sidecar' 'multi-review-pr.test.sh' \
' _record_desc "$out" "$dbodyf" || { rm -f "$bodyf" "$dbodyf"; die "cannot record the description digest" 1; }'

mutate 'pr/desc-digest-guard' 'scripts/multi-review-pr.sh' replace \
'replace-desc: hand-edited description clobbered or wrong status' 'multi-review-pr.test.sh' \
' if ! grep -qxF "$d" <<<"$recs"; then' \
' if false; then'

# The window opens at the FIRST heading. A PR body can carry a `## PR description` of its own;
# under "last wins" that decoy takes the window, the digest then never matches, and the
# description silently stops refreshing.
mutate 'pr/desc-first-heading' 'scripts/multi-review-pr.sh' replace \
'replace-desc: the decoy heading captured the window' 'multi-review-pr.test.sh' \
" hstart=\"\$(awk -v lim=\"\$bstart\" '/^## PR description[[:space:]]*\$/ && NR < lim { print NR; exit }' \"\$scratch\")\"" \
" hstart=\"\$(awk -v lim=\"\$bstart\" '/^## PR description[[:space:]]*\$/ && NR < lim { h = NR } END { if (h) print h }' \"\$scratch\")\""

mutate 'pr/desc-noop-unchanged' 'scripts/multi-review-pr.sh' replace \
'replace-desc: identical body rewrote or re-recorded' 'multi-review-pr.test.sh' \
' if [[ "$(_diff_digest < "$bodyf")" == "$(awk -v s="$dstart" -v e="$dend" '"'"'NR >= s && NR <= e'"'"' "$scratch" | _diff_digest)" ]]; then' \
' if false; then'

# Same swallow class as pr/splice-head-propagation, in the description writer.
mutate 'pr/desc-splice-head-propagation' 'scripts/multi-review-pr.sh' replace \
'desc splice: a failing head still exited 0' 'multi-review-pr.test.sh' \
' ( head -n "$((dstart - 1))" "$scratch" || exit 1' \
' ( head -n "$((dstart - 1))" "$scratch"'

mutate 'pr/refresh-fetches-desc' 'scripts/multi-review-pr.sh' replace \
'refresh: description still describes the ingested design' 'multi-review-pr.test.sh' \
' if ! ( cmd_replace_desc "$scratch" "${tmpd}/desc" ); then' \
' if false; then'

# The subshell is the guard, not decoration: without it `die` exits refresh after the diff swap
# and before the head record, leaving the round retryable and its anchors poisonable on the
# re-run (fable-rd1-r3, PR #131). The mutation is exactly the pre-fix line.
mutate 'pr/refresh-desc-die-contained' 'scripts/multi-review-pr.sh' replace \
'refresh: a fatal description swap left the round retryable' 'multi-review-pr.test.sh' \
' if ! ( cmd_replace_desc "$scratch" "${tmpd}/desc" ); then' \
' if ! cmd_replace_desc "$scratch" "${tmpd}/desc"; then'

# The skip must be SAID: a silent skip is the "documented instead of closed" shape #84 measured.
mutate 'pr/refresh-desc-skip-said' 'scripts/multi-review-pr.sh' replace \
'refresh: hand-edited description handling wrong' 'multi-review-pr.test.sh' \
' echo "multi-review-pr: PR description not refreshed for round ${round} — reconcile it against the PR by hand before seeding the copies" >&2' \
' :'

# The confirmation window. Every content read — diff AND body — now happens before it, so losing
# this guard re-admits both the stale-diff pairing it was added for and the post-push description
# (codex-rd1-r1, PR #131). It had NO entry and no covering assertion: deleted, the whole suite
# stayed green, which is exactly the shape this table exists to catch.
mutate 'pr/refresh-head-moved-refused' 'scripts/multi-review-pr.sh' replace \
'refresh: a moved head was accepted or the scratch was mutated' 'multi-review-pr.test.sh' \
' [[ "$head_after" == "$head" ]] \' \
' [[ -n "$head" ]] \'

# The description window is bounded BELOW by the verified diff window, so an unverifiable diff
# must refuse here too. This was recorded SURVIVES-BY-DESIGN behind the "no heading" refusal, and
# that reason was FALSE twice over (fable-rd1-r1, PR #131): nothing drove `replace-desc` through a
# failing `_locate_diff` at all, and deleting the named outer layer as well still refuses at the
# record check — so no assertion could ever have surfaced its loss. What the fall-through really
# costs is the DIAGNOSIS: the reader is told the document lacks a '## PR description' heading it
# plainly has. The assertion pins that, so this is a real caught entry now, not a survivor.
mutate 'pr/desc-window-needs-diff' 'scripts/multi-review-pr.sh' replace \
'replace-desc: unverifiable diff window wrote, or misdiagnosed the cause' 'multi-review-pr.test.sh' \
' span="$(_locate_diff "$scratch")" || return 3' \
' span="$(_locate_diff "$scratch")" || span=""'
Comment on lines +2956 to +2966

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 low — The SURVIVES-BY-DESIGN reason on pr/desc-window-needs-diff misstates its cover: no test drives replace-desc with an unverifiable diff, so the mutation survives because the path is never exercised, not because a covered outer layer masks it — and losing the "no heading" refusal would still not surface it, since hstart="" then yields an empty window whose digest the record check refuses anyway. — risk: a table entry whose recorded reason is false is the "reads correctly but cannot fail" shape the table exists to catch; a test that strips the diff record and asserts exit 3/untouched would make the reason true — 🤖 multi-review star review (claude-fable-5-1 + claude-opus-5)


mutate 'command/refresh-desc-skip-relayed' 'commands/multi-review.md' replace \
'says nothing about a description that was not refreshed' 'multi-review-packaging.test.sh' \
' `PR description not refreshed` on stderr. Relay that line, and reconcile the description' \
' a line on stderr. Relay that line, and reconcile the description'
# The one-way reconcile: without it the primary believes a later round will pick the description
# back up, and #85's drift returns silently for the rest of the review (fable-rd1-r2, PR #131).
mutate 'command/refresh-desc-reconcile-one-way' 'commands/multi-review.md' replace \
'command: the PR refresh step does not say a hand reconcile turns the description refresh off for good' 'multi-review-packaging.test.sh' \
' every later round skips it too — the description is then only as current as your last manual' \
' every later round refreshes it again — the description stays current on its own after your'

mutate 'command/gate-releases-via-helper' 'commands/multi-review.md' replace \
'terminal cleanup still hand-rolls the deletion' 'multi-review-packaging.test.sh' \
' ${CLAUDE_PLUGIN_ROOT}/scripts/multi-review-star.sh release "<doc>"' \
Expand Down
19 changes: 19 additions & 0 deletions scripts/multi-review-packaging.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1442,4 +1442,23 @@ grep -q 'multi-review-star.sh release "<doc>"' "$CMD" \
&& ok "command: the terminal gate releases working files through the helper" \
|| bad "command: terminal cleanup still hand-rolls the deletion (issue #112)"

# `refresh` now re-fetches the PR description under a digest guard and SKIPS one that was hand-edited
# (issue #85). The skip is a stderr line the primary must relay and act on; a fan-out step that never
# mentions it leaves the primary seeding copies whose description contradicts the diff — the exact
# cost the guard exists to remove. Per-line grep: the phrase must not wrap.
n="$(grep -n 'multi-review-pr.sh" refresh "<doc>" <N>' "$CMD" | head -1 | cut -d: -f1)"
if [[ -z "$n" ]]; then
bad "command: PR refresh step anchor not found — the guard's anchor is gone"
else
grep -q 'PR description not refreshed' <<<"$(sed -n "${n},$((n+14))p" "$CMD")" \
&& ok "command: the PR refresh step tells the primary to relay a skipped description refresh" \
|| bad "command: the PR refresh step says nothing about a description that was not refreshed (issue #85)"
# The reconcile is ONE-WAY: a hand-edited body matches no record, there is no `record-desc` to
# re-arm the guard, and only `ingest --fresh` re-seeds one — so every later round skips too and
# the description is only as current as the primary's last manual pass (fable-rd1-r1, PR #131).
grep -q 'every later round skips it too' <<<"$(sed -n "${n},$((n+22))p" "$CMD")" \
&& ok "command: the PR refresh step says the hand reconcile is one-way" \
|| bad "command: the PR refresh step does not say a hand reconcile turns the description refresh off for good"
fi

echo "packaging: $fails failure(s)"; [[ $fails -eq 0 ]]
Loading
Loading