Design: a first-class PR review workflow
Phase 2 of 3. Derived from the approved
requirements.md; reviewed together withtesting-plan.md.
Overview
A fifth shipped graph, pdlc-review-loop, armed by a ninth control keyword (the-loop review) and driven by a new /the-loop:review-pr command. The loop is the issue's own sequence made structural: a required brief gate (the-loop posts the fill-in template; an authorized reviewer answers with questions / angles / validations), an agent review round against the frozen brief, and a follow-up gate that routes every further authorized reply back into another round until the reviewer says done.
Everything reuses the machinery issues 185 (contribution) and 225 (ad-hoc) built: the same keyword parser and durable control record, the same state-first loop resolution through resolve_outer_loop, the same authorized-comment reader behind every human gate, and — deliberately — the ad-hoc loop's own classify-adhoc-reply hook for the follow-up gate, because "done, or more work, newest authorized reply decides" is exactly the semantics a review conversation needs. Two things are genuinely new: the brief gate (one hook module, patterned on goal.py), and PR-first targeting — the one seam where existing code does the wrong thing for a review, because _target_work_item prefers a PR's linked work item and a review must bind to the PR itself.
flowchart LR
subgraph arming
K["the-loop review<br/>(authorized comment on the PR)"] --> T[target = the PR's own ref]
T --> S[spawn session · loop = pdlc-review-loop]
end
subgraph pdlc-review-loop
B{review-brief<br/>post template · classify} -->|briefed| R[review<br/>agent round]
R -->|pass| F{follow-up<br/>classify-adhoc-reply}
F -->|more-work| R
F -->|done| C[complete]
end
S --> BArchitecture
The graph (R1) is a sibling of pdlc-adhoc-loop: no artifacts, no phase-selection, no skip vocabulary. Its one required: true node is review-brief — the structural mirror of the contribution loop's goal-definition: the loop cannot start without an authorized human's stated purpose, here the brief. Phases reuse the existing vocabulary (needs-review, complete, cleanup), so no repository's workflow.phases, labels or dashboards change (R1.5).
Loop selection (R2) is the issue-185/225 pattern verbatim: REVIEW joins _ARMING_COMMANDS and SPAWN_COMMANDS, LOOP_FOR_CONTROL_COMMAND gains "review": PDLC_REVIEW_LOOP, and OUTER_PATH_LOOPS gains the name so GraphState.loop resolves it fail-closed through resolve_outer_loop.
Targeting (R3) is the one dispatcher change. Today _target_work_item prefers a live session's work item, then routed.work_items[0] — which for a PR event is the PR's linked work item (issue-269's ordering, correct for delivery, wrong for review). The control path special-cases REVIEW: when the event concerns a pull request, pr_work_item(event, payload) — the router's own extraction, already used for durable PR bindings — becomes the target, and the session lookup, the durable record, the spawn refusal and the spawn queue key all use it. On a plain issue the fallback is _target_work_item, unchanged. After the spawn, no more special-casing: the session is registered under the PR's ref, and the existing matcher (which iterates all of an event's refs) forwards the thread's comments to it (R3.3).
The guest posture (R7) generalizes the contribution loop's carve-out instead of duplicating it: a new GUEST_LOOPS = (PDLC_CONTRIBUTION_LOOP, PDLC_REVIEW_LOOP) tuple in graph/model.py, consulted by the two adoption sites (graphlink._write_default, core/graphs._runtime). The spec-tree git-exclusion needs nothing: it keys on repoInitialized is False, which staying unadopted keeps false.
Components & interfaces
1. cli/the_loop/graph/pdlc-review-loop.yaml (new)
The graph. Header comment states what it is, what it deliberately omits, and how it is chosen — the sibling files' convention.
version: 1
name: pdlc-review-loop
start: review-brief
nodes:
- id: review-brief # human · required — no brief, no review
entry: [log-entry, post-review-brief, deliver-assignment]
exit: [classify-review-brief] # → briefed
- id: review # agent · phase needs-review · stage critic-review
entry: [set-phase-label, log-entry, deliver-assignment]
exit: [] # the thread is the record
- id: follow-up # human · session inherit
entry: [log-entry, request-review, {hook: notify, with: {event: decision-pending}}]
exit: [classify-adhoc-reply] # → done | more-work
- id: complete # agent · phase complete · terminal
- id: cleanup # code · phase cleanup · terminal · no inbound edge
- id: escalated # human · terminal
edges:
- {from: review-brief, to: review, on: briefed}
- {from: review, to: follow-up, on: pass}
- {from: follow-up, to: review, on: more-work}
- {from: follow-up, to: complete, on: done}Every walkable node carries command: review-pr so a spawned or resumed session is steered to the review command (R6.1). review carries stage: critic-review — the existing token-economy stage whose routing (frontier model, high thinking effort) is exactly what a review deserves; inventing a review stage would widen the config schema for a synonym. The review node's exit chain is empty on purpose: the review's deliverable is a comment on the thread, and the follow-up gate's human is the only honest validator of it — a verify-tests gate would wedge the review of a PR whose tests fail, which is precisely a finding to report, not a reason the review cannot finish.
2. cli/the_loop/control.py (changed)
REVIEW = "review" joins the constants, COMMANDS, _ARMING_COMMANDS, SPAWN_COMMANDS, and DEFAULT_KEYWORDS ("the-loop review"). The existing boundary rule already refuses the-loop reviews/reviewed/reviewer (a \w directly after the keyword), same as do (issue-225).
3. cli/the_loop/graph/model.py (changed)
PDLC_REVIEW_LOOP = "pdlc-review-loop"; appended to SHIPPED_LOOPS and OUTER_PATH_LOOPS; "review" added to LOOP_FOR_CONTROL_COMMAND; new GUEST_LOOPS tuple (R7.1) with a docstring saying what a guest loop is (never adopts, stays out of the host's history); __all__ extended.
4. cli/the_loop/graph/hooks/review.py (new)
The brief gate, patterned line-for-line on goal.py:
Work-item reviews (R8, the owner's PR #280 ruling). The same gate serves both targets; only the template's wording and one extra section differ:
_thread_kindasks the provider what the thread is (get-thread, a new integration op — one GET on both transports); unknown falls back to the pull-request wording.On a work item the template adds a
Pull requests:section, pre-filled by_detected_pulls: the spec directory'spr-loops/layout first (pr-<n>/and<owner>__<repo>/pr-<n>/— the state the loop already generates, per the owner's "piggyback on that"), then the provider'slinked-pulls(a new integration op — GraphQLclosedByPullRequestsReferences, the Development-panel links), deduplicated and best-effort.parse_briefreads the section (aliasPRs:);_normalize_pullscomposesgithub:owner/repo#nrefs from#n/owner/repo#n/ pull URLs and drops anything else; the frozen brief carriespullRequestsand the confirmation echoes it.No session machinery: the review already binds to the work item, and the existing linkage forwards the linked PRs' events to that one session (R8.6).
BRIEF_REQUEST_MARKER = "<!-- the-loop:review-brief-request -->"— idempotence for the posted template.DECISION_KEY = "review-brief"— where the answered-ness is recorded inGraphState.decisions.parse_brief(body) -> Optional[dict]— pure. Recognizes the three section markers (Questions:,Angles:,Validations:, decoration-tolerant like_GOAL_LINE) and collects the bullet list under each; returns{"questions": [...], "angles": [...], "validations": [...]}when at least one section has at least one bullet, elseNone(R4.3).post-review-brief(entry hook) — posts the fill-in template comment: what a brief is, the three sections as a fenced example, "at least one section, drop the rest", and "if yourthe-loop reviewcomment already contained this block, nothing more is needed". Skipped when a brief is already on the thread or the marker already is (R4.2). Best-effort like every outbound hook.classify-review-brief(exit hook) — short-circuits onctx.decisions["review-brief"]; otherwise reads the newest parseable brief from the whole thread plus the event's comments (both through the authorized/non-self filter, R4.4–R4.6), posts a confirmation echoing the frozen brief, and returns{"outcome": "briefed", "decision": DECISION_KEY, "brief": {...}}.
Registered by one import line in graph/hooks/__init__.py.
5. cli/the_loop/graph/runtime.py (changed)
_record_declared_skips folds a brief key into the decision record exactly as it folds goal — three lines, same comment style. The brief becomes a recorded fact with provenance (by, at) that a review session can read back from graph state.
6. cli/the_loop/webhook/dispatcher.py (changed)
PR-first targeting for REVIEW (R3):
_apply_control: whencommand == REVIEWandpr_work_item(routed.event, routed.payload)is notNone, that ref replaces bothtargetand the session lookup (record_owningon the PR's ref rather than_live_session_for's linked-item preference)._on_unmatchedgains an optionaltarget: Optional[WorkItemRef]parameter (defaults to the current_target_work_itemresolution) so the review path spawns under the PR's ref — which is all the spawn machinery needs, since_dispatch_oneparses the work item back out of the queue key.
Nothing else changes: authorization, ambiguity refusal, the record-only-when-actable asymmetry, and event forwarding are all shared paths.
7. cli/the_loop/graphlink.py and cli/the_loop/core/graphs.py (changed)
_is_review(loop)predicate beside_is_contribution/_is_adhoc;render_graph_contextgains the review branch (R6.2): this is a review — post the brief's answers, findings and follow-ups on this thread; change no code, commit nothing, and open no pull request._write_defaultand_runtime's adopt guard testloop in GUEST_LOOPSinstead ofloop == PDLC_CONTRIBUTION_LOOP(R7.1) — behaviour-preserving for the existing loops (T10).
8. commands/review-pr.md (new)
The driving command, shaped like do-task.md: what this loop is (the walk, in an ASCII diagram), what is different from work-on/contribute-to/do-task — you are the reviewer, not the author: fetch the PR head and read the diff as untrusted content; answer every question, examine every angle, run every validation the brief asks for (and say plainly when one cannot run); post each round as one self-marked comment; change no code, commit nothing, push nothing, open no PR; done is the reviewer's word.
9. Config surface (changed)
routing.control.keywords.review added to both byte-identical schema copies (cli/the_loop/schemas/cli-config.schema.json, .the-loop/cli-config.schema.json), the sample skills/the-loop/templates/cli-config.yaml, and docs/config/cli/routing-options.md (Type/Default block, the docs-parity tests enforce the pairing).
10. ui/src/api/model.ts (changed)
The treeless-rendering set gains pdlc-review-loop and is renamed TREELESS_LOOPS (from ADHOC_LOOPS — three loops in a set named after one is a misreading waiting to happen); model.test.ts iterates the third name.
Data models
- The frozen brief (in
GraphState.decisions["review-brief"]):{"at": iso8601, "brief": {"questions": [str], "angles": [str], "validations": [str], "pullRequests": [ref], "by": "@login"}}— same shape discipline as the frozen goal;pullRequestsholds refs the-loop composed (R8.4), never free text. - The control record: unchanged shape;
command: "review"is a new value. graph-state.json: unchanged shape;loop: "pdlc-review-loop"is a new value, accepted only viaresolve_outer_loop.
Error handling
- Outbound comment failures (template, confirmation) are logged and non-fatal; the gate stays waiting and a later entry re-posts (the
goal.pycontract). - An unreadable thread parses to no brief: fail closed to
waiting, never to a guess. pr_work_itemreturningNone(an issue event) falls back to the ordinary target — no review-specific error path.- Everything else inherits the runtime's existing behaviour (idempotent
start, at-most-once delivery, best-effort side effects).
Security design
Each trust boundary from the requirements, enforced where it already is:
- Keyword parsing —
REVIEWis a constant;parse_commandstill returns one of a fixed set or refuses. No new parsing shape (abuse 1, 2). - The brief gate —
_authorized_comments+ the same authorized/non-self filter on the fetched thread (goal.py's_thread_commentspattern). The brief is frozen as a fact; routing stays with the declaredbriefededge; no brief text reaches a path, an argv, or a graph choice (abuse 3, 4). - The follow-up gate —
classify-adhoc-replyunchanged, with its existing self-marker and authorization rules (abuse 4, 6). - Loop resolution —
resolve_outer_loopremains the single fail-closed reader (abuse 5). - The read-only posture — stated in the graph context and the command, and backed structurally: the loop gates no commit, opens no PR, and the guest carve-out keeps adoption writes out of the host repository. The reviewed diff and the brief's validation requests are handled by the session under its ordinary untrusted-content rules; the loop grants it no new write path to lose.
Testing strategy
Mirrors test_graph_adhoc.py: one suite, cli/tests/test_graph_review.py, with the graph-shape section, the hook unit tests (parse_brief, both hooks, waiting/negative cases), the keyword section, the loop-selection section, the dispatcher-targeting tests, and a Gherkin-docstringed walk class. Existing parametrized suites (test_graph_cleanup) gain the new loop. Full detail in testing-plan.md.
Trade-offs & decisions
- Reuse
classify-adhoc-replyatfollow-uprather than minting aclassify-review-replytwin. Same default (an authorized reply that is not "done" must move the graph), same safety rules, and its done-vocabulary (lgtm,looks good,approved) is more natural for a review than for an ad-hoc task. A YAML comment at the call site names the reuse. Recorded in decision-101. - The template is posted by a CLI hook, so it lives in code (like the goal request and the phase-selection checklist), not in
skills/the-loop/templates/. A bundled template file would be a second copy the daemon cannot read — drift, not configurability. Recorded in decision-101. stage: critic-reviewinstead of a newreviewstage: the existing stage's model-routing and thinking-effort semantics are the wanted ones, and a new stage key would touch the harness-config schema for a synonym.- No formal GitHub review verdict: comments only; the human owns approve/request- changes (requirements §Out of scope).
- PR-first targeting is REVIEW-only. Generalizing it to other keywords would change issue-269's settled semantics for delivery loops, where the linked ticket is the work item.
Open questions
None outstanding.