A long-running loop over a single open PR. Each iteration polls for new
review comments, classifies them, defers conflicts to a human, calls
tp-pr-fix.run_round for the structural subset, and re-enters the wait.
Terminates on:
-
Run first-run preflight per skills/_shared/first-run.md. (Runs once at
loop open, before the first iteration.)
-
Backoff sleep — time.sleep(_compute_next_wait(prev_wait)). First
pass uses 60s.
-
Poll — gh pr view <pr_url> --json comments,reviews (and
gh api .../pulls/.../comments for line-anchored review comments). Also
fetch the review threads via thread_resolver.list_review_threads(pr_url)
(GraphQL — carries each thread's thread_id + first-comment comment_id).
2.5. Dual-source — fan out a MULTI-ANGLE /code-review ∥ the Copilot poll (Enhancement 1).
Concurrently with the GitHub poll, run a local review so the loop never depends on
Copilot's single signal. A single /code-review subagent cannot fan out into the
skill's own multi-angle finder/verifier harness (L23 — a subagent can't spawn
sub-subagents), so a lone dispatch is a single-pass review that misses what the full
harness catches. The loop driver runs at the top level, so it fans out the
angles ITSELF — several parallel finder dispatches (each a 1-level fan-out), merged
fail-closed:
ANGLES = [
("correctness-leak", "Hunt fail-closed/correctness leaks: any path to a wrong "
"result, a non-handled error, an unsafe edge case."),
("edge-cases", "Boundary/degenerate inputs, ordering dependence, off-by-one, "
"type-coercion, missing-key, empty-collection behavior."),
("test-quality", "Do the tests actually guard their claims? Untested branch, "
"a test that passes even if the code were wrong, missing case."),
]
# `effort` is supplied by the driver: run_loop calls poll_fn(effort) where
# effort == _codereview_effort(state) ("max" after a stalled code-review round,
# else "high"). Apply it to each angle's /code-review invocation.
responses = [
Agent(subagent_type="general-purpose", description=f"code-review:{name}",
prompt=f"Review `git diff {base}...{head}` for: {angle}. Run at "
f"--effort {effort}. Read .github/review-instructions.md for what "
"counts as a real defect here. "
"Return ONLY a fenced ```json array of {file, line_range:[start,end], "
"summary, verdict} (verdict: structural|minor). [] only if genuinely clean.")
for (name, angle) in ANGLES
]
codereview_findings = review_merge.merge_codereview_angles(responses) # fail-closed parse + dedupe
# MANDATORY, every invocation — post the review summary to the PR so it is
# never silent (parallel to a Copilot review). Fires even when findings == [].
review_merge.post_codereview_comment(pr_url, codereview_findings, head_sha=head_sha)
- Multi-angle, not single-pass. Dispatch the angle set every round at the
--effort level the driver passes in. The driver escalates to --effort max
when the prior round had its own code-review structural findings —
state.consecutive_codereview_structural_rounds >= 1, the code-review-specific
counter, NOT the Copilot/thread consecutive_structural_rounds. The escalation
is computed by _codereview_effort(state) and reaches the poll via
poll_fn(effort) (run_loop passes it whenever poll_fn declares an effort
parameter). Fanning out at the driver level is the only L23-safe way to get
harness-grade coverage inside the loop; a single /code-review subagent
reviewing in one pass demonstrably misses real defects.
- Fail-closed parse — an UNPARSEABLE review is NOT a clean one.
review_merge.merge_codereview_angles (via parse_codereview_findings_or_block)
parses each angle with parse_codereview_result, which distinguishes a genuine
[] (clean) from "no parseable JSON array" (unparseable). An unparseable angle
contributes a structural _unparseable_finding sentinel rather than collapsing
to [], so a silent parse failure can never read as clean and false-converge
the two-stable terminal. Never use the bare parse_codereview_response on the
convergence path.
- Posting the summary comment is mandatory on every invocation, including a clean
(
[]) result — review_merge.post_codereview_comment renders a Copilot-style
body grouped by severity and posts it via REST. No silent reviews: each round
leaves a visible, auditable PR record (a parse failure shows as a structural
"could not be parsed" finding, not "no findings"). Fail-open (a failed post is
logged, never crashes the loop) but always attempted.
- All angles + Copilot are driven by the shared
.github/review-instructions.md (the
local angles are handed it in the prompt; Copilot reads the synced
.github/copilot-instructions.md) so "stable" means the same thing on each side
and known-intentional patterns aren't re-flagged.
- Shell
run_round.py with the fan-out result. After the ANGLES fan-out and
post_codereview_comment, the standalone path drives the round decision the same
way as the orchestrator: pass the real fan-out findings (or the no-angles sentinel
on failure) to run_round.py via its stdin JSON contract (see run_round.py).
The stdin object must include config (read from .three-pillars/config.json)
and ci_rollup (the most-recent statusCheckRollup) so the wrapper resolves
review.expects_copilot and ci.expects_github_checks correctly. Omitting config
defaults both to true, which blocks code-review-only convergence on repos without
Copilot. (F-P1)
Never rely on an in-context self-pass as the review source — a single-context
/code-review that runs in the same LLM context as the loop is not an independent
review and must never be signed /code-review in the convergence path. The honest-
attribution rule: only a real top-level ANGLES fan-out (or a cached finding from one)
may satisfy _independent_review_ran; a same-context pass does not.
-
Pre-classification checks — call _poll_step(state, new_comments, now, config). If terminal, persist + return.
-
Heuristic prefilter — for each comment, call
from skills._shared.classifier_heuristic import classify directly
(no Agent — this is pure deterministic Python). Split into decided
and borderline.
-
Sonnet judge on borderlines — invoke the model via SKILL prose:
prompt = classifier_judge.build_prompt(borderline, diff_context)
response = Agent(
subagent_type="general-purpose",
model="claude-sonnet-4-6",
prompt=prompt,
description="classify-borderline-pr-comments",
)
classified_borderline = classifier_judge.parse_response(response)
classifier_judge only constructs the prompt and validates the
response against classified-comment.v1.json — it has no
import anthropic (asserted by Task 5.8's ast.parse invariant
test). The model invocation is the only step that lives in this
prose, not in the helper.
-
Merge — classified = decided + classified_borderline.
6b. Normalize + dedupe both sources (Enhancement 1). Map the classified
Copilot comments via review_merge.normalize_copilot and the
codereview_findings via review_merge.normalize_codereview, then
review_merge.dedupe(union). On a file + line-proximity + summary-similarity
collision the Copilot finding wins (it carries the thread_id
reply-and-resolve needs) and the dropped twin's id is recorded in
merged_from. The deduped union is the kept set handed to the fix round —
each iteration deals with both reviews at once.
-
Conflict-defer — _apply_conflicts(state, classified, now). If
every structural comment conflicted, terminal awaiting-human-review
with note [all-conflicting-deferred-to-human].
-
Compute round verdict — derive last_verdict from the kept set:
structural-present if any kept comment has verdict="structural",
else minor-only. The classifier-flip from structural-present to
minor-only is a necessary precondition for success, not a terminal
on its own. Never transition to awaiting-human-review on the flip alone —
success is decided only by two-stable (step 10b), which additionally
requires zero unresolved actionable threads verified against GitHub. A flip on
a stale snapshot with threads still open is the convergence bug this rule
exists to prevent.
-
Dispatch fix round — when --dry-run is OFF and last_verdict
warrants action: fix_round.run_round(design, pr_url, iteration, classified=kept, head_ref=head_ref, loop_mode=True). head_ref is resolved
once at loop-open via gh pr view <pr_url> --json headRefName (F1: the
fix must land on the actual PR head — an orchestrator PR's head is
candidate/{slug}/single, not tp/{design}; loop_mode=True auto-checks-out
the head before committing). Accumulate envelope.diff_lines_added into
state.cumulative_diff_lines. Persist the envelope under
<worktree>/.three-pillars/run/fix-envelope.iter-N.json.
9.5. Re-request Copilot review + Reply-and-resolve every Copilot thread (load-bearing, Enhancement 1).
Per-round Copilot re-request is handled by _request_copilot_review(pr_url) (fail-open;
a non-zero return or raised exception returns False and the loop continues). The loop
sets phase awaiting-copilot around the subsequent CI/Copilot wait (see
_ci_settled_on_head). After the wait, the loop proceeds to classify.
Reply-and-resolve every Copilot thread:
The reply ALWAYS precedes the resolve — the loop never resolves a thread
without first leaving the evidence reply. Call the shared helper:
result = thread_dispose.dispose_threads(
pr_url,
envelope,
resolved_ids=state.resolved_thread_ids,
author=pr_author,
)
state.resolved_thread_ids.update(result["resolved"])
thread_dispose.dispose_threads (from skills/_shared/thread_dispose.py)
owns the single source of truth for the reply-and-resolve sequence: it
calls thread_resolver.disposition_for for every open thread (never
hand-judges), signs replies with the worker identity
(🤖 three-pillars-worker (on behalf of @{author})), calls
reply_to_thread before resolve_thread for each thread, and is
idempotent (skip already-resolved; skip already-replied). The same
helper is called by --dispose-only mode out-of-band.
Disposition is addressed (links the fixing commit), stale (cites the
prior-round resolution as evidence), or deferred (reason). Copilot
re-posts comments anchored to unchanged diff lines every round; without
reply-and-resolve the loop re-litigates already-fixed items forever and
the new-vs-stale signal is unusable. Resolve uses GraphQL
resolveReviewThread — never gh pr edit (broken on this repo). Track
every observed thread_id in state.seen_thread_ids and every resolved
one in state.resolved_thread_ids.
9.5 is mandatory every round, and disposition is ONLY ever disposition_for's
output — never a hand-judged "stale". A finding is stale only when its
thread_id is already in resolved_thread_ids (resolved in a PRIOR round); a
brand-new thread is addressed (fix landed) or deferred (stays open for the
human) — never stale. Every observed thread MUST end the round either
resolved (reply-and-resolve) or explicitly deferred; a thread left neither
resolved nor deferred BLOCKS every success terminal. This is the rule the
wave1-0605 #56 run violated — it hand-labeled a brand-new Copilot round as
"stale re-flags," skipped 9.5, and declared a bare classifier-flip; the
ground-truth assertion in step 10b now makes that impossible.
-
Guard checks — _apply_guards(state, pr_url, config, now). If
terminal (cap-exhausted | convergence-failure), apply the F9 label
tp:needs-human-attention and persist.
10b. Two-stable termination (the only success terminal). The round decision step
(run_round.py / loop_driver.run_round) evaluates four conjuncts before converging.
Only when all hold in the same round do you transition to awaiting-human-review:
last_verdict == "minor-only" — the classifier-flip (step 8). Necessary but not
sufficient: a flip on a snapshot taken before CI-settle or a late Copilot round
cannot be trusted.
_ci_all_success(ci_rollup, config) — CI settled on this head and all checks pass.
unresolved_actionable == 0 — a ground-truth re-fetch (unresolved_actionable_fn)
confirms zero unresolved actionable threads. The in-loop bookkeeping alone is not
the gate (fail-closed on an unverifiable / missing fn).
_independent_review_ran (new, M2) — a real, independently-dispatched
/code-review fan-out ran for the current head and its findings are non-degraded
(review_available), OR Copilot has reviewed (copilot_reviewed_successfully(pr_url)).
Computed as:
(expects_copilot and reviewed is True) OR review_available, where
review_available = (last_codereview_head_sha == head_sha and head_sha is not None) and not is_degraded_review(codereview_findings).
This is the M2 current-head guarantee: a real review of a prior head does
NOT satisfy the conjunct. A same-context self-pass is never review_available —
only a real top-level ANGLES fan-out (or a cache of one for the exact current head)
qualifies.
When conjuncts 1–3 hold AND conjunct 4 is False (no independent review ran for
this head): transition blocked-no-independent-review, apply tp:needs-human-attention
and remove any stale tp:ready-for-human-merge (terminal label hygiene — a prior
convergence's label must not mask a later blocked head), append the
BLOCKED — NEEDS REVIEW — no proof (no independent review ran) line to decisions.md. This is a
terminal — run_round returns terminal="blocked-no-independent-review" and
run_loop stops; tp:needs-human-attention escalates for human follow-up (never apply
tp:ready-for-human-merge here). Bounded re-run / not-converged: see proof-of-review.md.
When all four hold: transition awaiting-human-review with termination_reason="two-stable",
apply tp:ready-for-human-merge (via _ensure_pr_label) and remove the sticky
tp:needs-human-attention (label_manager.remove_pr_label, probe-first REST — it is
also applied on recoverable paths and nothing else clears it; without this a
recovered-then-converged run reads as fleet trouble), append the [pr-readiness/terminal]
line to decisions.md. The loop is fail-open on the readiness check: a
missing/erroring reviewed_fn is UNVERIFIABLE → keep iterating to a cap/idle terminal.
Copilot-optional terminal (review.expects_copilot). The Copilot conjunct
(reviewed is True) is required only when Copilot is an available reviewer —
_expects_copilot_review(config), reading review.expects_copilot from
.three-pillars/config.json (default true). When it is false (structural
entitlement absence), the Copilot disjunct of _independent_review_ran is dead, so
review_available alone carries the fourth conjunct — the dual-source /code-review
arm is the load-bearing reviewer. The transition note is two-stable [code-review-only];
termination_reason stays "two-stable"; the canonical clean-round finish is
scripts/converge.py (ordered: post proof digest → shell run_round.py). expects_copilot true is unchanged.
Honest-attribution rule. A single-context self-pass (a /code-review running
inside the same LLM context as the loop, reading the diff in-context rather than as
a separate Agent dispatch) is NOT an independent review. It must never be signed as
/code-review on the convergence path. Only a real top-level ANGLES fan-out — or a
cache of one for the current head — may satisfy review_available.
-
Persist iterate-state — atomic write to
<worktree>/.three-pillars/run/state.json under the iterate
namespace (including seen_thread_ids / resolved_thread_ids /
termination_reason). Update last_loop_sha from git rev-parse HEAD
after a successful push, so the next iteration's human-push detector reads
a fresh baseline.
Use this for out-of-band disposition — e.g. after a manual head-SHA bump,
after Copilot re-fires outside a loop run, or when review threads arrive
between loop iterations. The in-loop path (step 9.5) and the out-of-band
path both call the same thread_dispose.dispose_threads primitive (single
source of truth).
The loop polls, classifies, reply-resolves, and re-requests Copilot (steps 2 / 2.5 / 9.5 /
the per-round re-request). Three GitHub-side quirks bite that flow — all observed 2026-06-04
across PRs #45/#46. Get any of them wrong and a round silently misreads Copilot's signal.