review-panel
Tier 2 · domain. Depends on evidence and session-read.
npm install @titan-design/review-panelStatus: types, ports, the classifier (classifyPr, DEFAULT_CLASS_RULES), the planner (planPanel, DEFAULT_PANEL_POLICY, DEFAULT_PANEL_TABLE), the reviewer briefs, the verdict acceptor (acceptVerdict) and the aggregate (aggregate).
The problem it solves
A pull request review is one reviewer with one brief, wired into Shepherd. A PR that touches authority or policy needs more than one question asked of it (does it do what it claims, and does any input read as allowed when it should not), and a second caller, such as a local CLI, wants the same review without running Shepherd.
This package holds the vocabulary of a review panel and the ports a caller satisfies, so every caller plans, briefs and aggregates the same way:
PrFacts: what the caller knows about the PR (repo, number, head, base, kind, changed files with line counts). Classification reads nothing else.PrClass: the class (g10orstandard) and the touch flags that choose the panel's shapes.changedLineCount(files, rules?): additions plus deletions with thegeneratedglobs (CAPABILITIES.md, site reference pages, the reference sidebar, the capabilities guide,.codewatch/check.jsonanddead-exports.json, changesets,packages/*/api/*.d.tsreports) left out; the lockfile and snapshots still count; undefined when any file has no line counts. A rename counts as generated only when it came from a generated path too, and generated lines that alone passlargeLines(400) count in full, since that is a hand edit. A PR is large pastlargeLinesof it. Shepherd sizes its reviewer the same way, and an unread size there takes the g10 class.touchesDependency(files, rules?): true when a changed file, or the path it was renamed from, matchesClassRules.dependency(pnpm-lock.yaml,pnpm-workspace.yaml, anypackage.json).classifyPradds thedependencytouch, which is strict, so such a PR isg10at any size.ReviewerFacts.dependencyTouchcarries the same fact to Shepherd's reviewer sizing, andReviewerFacts.g10Holdmarks a run that holds or held a G10 hold, which takes the g10 reviewer at any size.securitySliceFlag({ kind?, changedFiles }, rules?):{ lines, cap, guardPaths }when a slice is pastSECURITY_SLICE_LINES(400) non-generated lines and is kindsecurityor touches a non-generatedsecurityorauthoritypath; undefined otherwise, including an unread count. Advisory: the 2026-10-11 review-rounds audit found larger security slices rarely converge within 3 review rounds.PanelPlan: the members (one perReviewShape), each with its profile, brief id, and whether it blocks or was degraded to sonnet, plus a spend estimate.planPanel(cls, policy, headroom): the pure planner. The correctness member always runs, at the policy'srolesprofile for the class. Apaneltable adds shapes by class and touch, in priority order; with no table the plan is correctness alone, which is how Shepherd routes its single reviewer today. At most 3 members and 1 opus member; a later opus member is planned at its sonnet profile.headroom.opus === falseplans every opus member at its sonnet profile withdegraded: true.PanelVerdict: the panel's outcome in Shepherd's vocabulary (MERGE,FIX_FIRST,no-verdict,timeout), the labelled findings, the dissenting members, andsatisfiesG10.- The ports
ReviewerDispatch(roster, spawn, resume),ReviewerReader(a reviewer's assistant messages) and their rowsReviewerAgentandReviewerMessage, withReviewTargetnaming the head under review.ReviewerMessage.syntheticis set only from a client-written record's own fields. acceptVerdict(input, messages): the pure acceptor. It takes only the final message of the dispatched agent and session, written after dispatch, whoseVerdict:block names the PR at this head (namesTarget; repo case is ignored). A refused or misaimed block isnonewith amalformedrecord (readMalformed,MALFORMED_REFUSALS); a MERGE or FIX_FIRST from a session with no investigative call (isInvestigativeCall) isnonewithDEPTH_FLOOR_REASON. A FIX_FIRST keeps its findings (fixFirstFindings, bounded byboundedFindings), and a verdict keeps the reviewer's OWNER-BRIEF block (parseOwnerBrief). A MERGE carries the reviewer'sCI-policy:line asciPolicywhenparseVerdictBlockreads one. A verdict of either kind carries the reviewer's proposedFollow-ups:tasks asfollowUps, orfollowUpsWarningwhen the block was dropped; neither changes the verdict (parseFollowUpsbelow). A short usage-limit notice the client wrote itself (asyntheticmessage) isnonewithUSAGE_LIMIT_REASON, the notice and any recordedresetsAt;isUsageLimittells that result apart. The same words from the reviewer are malformed.parseFollowUps(text): the optionalFollow-ups:section, one- <title> | <done_when> | <severity>line per task directly under the heading, severitylow,mediumorhigh, at mostMAX_FOLLOW_UPS. It may sit before or after the verdict lines; fenced or commented lines are not read (session-read'svisibleLines).Follow-ups: noneproposes nothing. Any fault drops the whole block with one warning fromFOLLOW_UPS_WARNINGS(multiple_blocks,bad_item,bad_severity,too_many,too_long,placeholder), so a block is never filed in part.followUpsFieldsgives the two optional fieldsacceptVerdictadds, andFOLLOW_UPS_RULEis the sentencereviewerBriefplaces before the verdict lines.aggregate(plan, results, input): the pure panel verdict from the members' results (MemberResult: shape, accepted verdict ornoneortimeout, degraded at spawn). Only a verdict of exactlyMERGEorFIX_FIRSTnaminginput.headcounts; any other value is a missing member. Any blocking FIX_FIRST makes itFIX_FIRSTand lists that member indissent;MERGEneeds every blocking member's MERGE. A blocking member with no verdict isno-verdict(timeoutwhen every such member timed out), and a plan with no blocking member isno-verdict. Advisory members never block; their FIX_FIRST findings ride along withblocking: false, and each finding that cites nopath:lineexisting ininput.source(checked withevidence) is dropped. A blocking finding is kept whatever it cites. Findings are in shape order, each member bounded to an equal share of 16,000 characters.satisfiesG10needs a g10 class, a MERGE, no degraded member, the correctness member at an opus profile (a key ofsonnetFor) and the adversary's MERGE.reviewPullRequest(request, ports, opts?): the one entry point. It classifies the PR, plans the panel (askingports.headroom()once), runs every member in parallel and returnsaggregate's verdict. Each member is spawned undermemberName(target, shape)(rp-<owner>-<repo>-<pr>-<head 8>-<shape>), with its planned profile inReviewerFacts.profile; a name already on the roster is reused and nothing is spawned, so a repeat call with the same input spawns nothing. An opus spawn the dispatch refuses is tried once at its sonnet profile and the member is degraded; a second refusal is anonewithSPAWN_REFUSED_REASON. The wait pollsports.readerthroughacceptVerdict. A malformed reply gets onecorrectionPromptper member, sent byresume; silence past the deadline or an uncorrected reply gets one fresh reviewer under the-r2name, and a member still silent after it is atimeout. A usage-limit notice is final.
When to reach for it
Use it when you start reviewers for a pull request and read their verdicts, and want the same types Shepherd uses. To start an agent, use agent-dispatch inside your ReviewerDispatch adapter; this package never imports it. To parse a transcript into messages for your ReviewerReader, use session-read.
Example
Verified against 0.0.0 (unreleased).
import type { ReviewerDispatch, ReviewerReader } from "@titan-design/review-panel";
const dispatch: ReviewerDispatch = {
roster: async () => [],
spawn: async (name, brief, target) => {
console.log(`spawn ${name} in ${target.repo} at ${target.head}`);
},
resume: async () => {},
};
const reader: ReviewerReader = {
read: async () => [],
};import { classifyPr, DEFAULT_PANEL_POLICY, DEFAULT_PANEL_TABLE, planPanel } from "@titan-design/review-panel";
const cls = classifyPr({
repo: "acme/app",
pr: 7,
head: "abc123",
base: "def456",
kind: "feature",
changedFiles: [{ path: ".github/workflows/ci.yml", additions: 4, deletions: 1 }],
});
const plan = planPanel(cls, { ...DEFAULT_PANEL_POLICY, panel: DEFAULT_PANEL_TABLE }, { opus: true });
// plan.members: correctness at bd-reviewer, adversary at reviewer; both blockingimport { SHAPE_BRIEFS, shapeBrief } from "@titan-design/review-panel";
const brief = shapeBrief("adversary", {
repo: "acme/app",
pr: 7,
head: "0123456789abcdef0123456789abcdef01234567",
checkoutRoot: "/srv/reviews",
});
// the adversary overlay, then the base reviewer brief; SHAPE_BRIEFS.adversary.hash names this variantWhat it deliberately does not do
- It runs nothing. Every side effect (starting an agent, reading a transcript, the clock, spawn headroom) is a port the caller passes in.
- It does not import
agent-dispatch,workflow,github, or any product. - Durability is the caller's. Shepherd records each step itself; the package keeps no state.
reviewPullRequestholds nothing between calls; its idempotence comes from the member names.
Gotchas
ReviewerAgent.presenceis a closed union. Map any unlisted roster value tounknown, which nothing treats asexited,detachedorderegistered.ReviewerAgent.predecessorandfillTokensabsent mean unknown, and Shepherd never resumes such an agent. Report them when your roster knows them.ReviewerReader.readreturns messages oldest first; the last one is the final message.PanelPlan.spendEstimateis an estimate in agent-chat points, never a cap. The default weights (DEFAULT_MEMBER_POINTS) are placeholders until the scorecard measures spend.- A profile counts as opus only when
sonnetFormaps it to a sonnet profile; an opus profile missing from that map is never degraded or capped. - A shape brief's
hashchanges with any edit to its overlay; record the hash with each verdict so evals compare variants, not ids. - The tests brief reads only the verdict, counts and flags of a
fix-proof/v1line. A line that does not parse, or names another head, is ignored. reviewerBrief'sfixProofUnverified(error,untrustedorunread) adds a blocking section: FIX_FIRST unless the reviewer names an added test that fails on the base. A reason outside that set adds nothing, and a reproduced line no longer stops the reviewer rerunning checks.reviewerBriefasks every FIX_FIRST for aDefect class:section, first review included. ItsearlierFindings(the run's earlier FIX_FIRSTs, oldest first) render betweenEARLIER-FINDINGSandEND-EARLIER-FINDINGS, cut from the oldest end atMAX_FIX_FIRST_TEXT_CHARSand only then stripped of verdict-block lines and both markers (in any case), so a mid-line cut cannot recreate either. The brief then asks for anEarlier findings:checklist of- fixed:and- not fixed:lines, and for an earlierDefect class:line copied exactly when the class recurs.reviewerBriefasks every MERGE, on any diff, for aCI-policy: unchangedorCI-policy: weakened <keys>line directly afterHead:(CI_POLICY_RULE, which gives each key a one-line meaning).correctionPromptrepeats it with the key names only, since it resumes the session that already read the brief, and stays withinMAX_CORRECTION_PROMPT_CHARS.testsis advisory in the plan.aggregatemakes it block wheninput.fixProofisvacuous,no-testsorerror, and then its MERGE is needed too. When the plan has notestsmember, that seat counts as missing, so the panel never says MERGE.aggregatereadssonnetForfrom its input, not the plan. Pass the same table you gaveplanPanel, orsatisfiesG10stays false for a custom opus profile.
Where it came from
Slice 1 of TP-1916. The ports moved from Shepherd's review.ts in the factory product, unchanged, so its reviewer-dispatch.ts and reviewer-reader.ts adapters satisfy them as they are. The factory now imports them from here. Slice 3 moved acceptVerdict, the owner-brief parser, the Malformed refusals, namesTarget and the depth floor out of Shepherd's await-verdict.ts, review-schemas.ts, verdict-target.ts and depth-floor.ts, unchanged; a differential test runs a frozen copy of the old acceptor against recorded messages to prove it.