Skip to content

review-panel ​

Tier 2 · domain. Depends on evidence and session-read.

sh
npm install @titan-design/review-panel

Status: 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 (g10 or standard) and the touch flags that choose the panel's shapes.
  • changedLineCount(files, rules?): additions plus deletions with the generated globs (CAPABILITIES.md, site reference pages, the reference sidebar, the capabilities guide, .codewatch/check.json and dead-exports.json, changesets, packages/*/api/*.d.ts reports) 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 pass largeLines (400) count in full, since that is a hand edit. A PR is large past largeLines of 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, matches ClassRules.dependency (pnpm-lock.yaml, pnpm-workspace.yaml, any package.json). classifyPr adds the dependency touch, which is strict, so such a PR is g10 at any size. ReviewerFacts.dependencyTouch carries the same fact to Shepherd's reviewer sizing, and ReviewerFacts.g10Hold marks 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 past SECURITY_SLICE_LINES (400) non-generated lines and is kind security or touches a non-generated security or authority path; 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 per ReviewShape), 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's roles profile for the class. A panel table 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 === false plans every opus member at its sonnet profile with degraded: true.
  • PanelVerdict: the panel's outcome in Shepherd's vocabulary (MERGE, FIX_FIRST, no-verdict, timeout), the labelled findings, the dissenting members, and satisfiesG10.
  • The ports ReviewerDispatch (roster, spawn, resume), ReviewerReader (a reviewer's assistant messages) and their rows ReviewerAgent and ReviewerMessage, with ReviewTarget naming the head under review. ReviewerMessage.synthetic is 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, whose Verdict: block names the PR at this head (namesTarget; repo case is ignored). A refused or misaimed block is none with a malformed record (readMalformed, MALFORMED_REFUSALS); a MERGE or FIX_FIRST from a session with no investigative call (isInvestigativeCall) is none with DEPTH_FLOOR_REASON. A FIX_FIRST keeps its findings (fixFirstFindings, bounded by boundedFindings), and a verdict keeps the reviewer's OWNER-BRIEF block (parseOwnerBrief). A MERGE carries the reviewer's CI-policy: line as ciPolicy when parseVerdictBlock reads one. A verdict of either kind carries the reviewer's proposed Follow-ups: tasks as followUps, or followUpsWarning when the block was dropped; neither changes the verdict (parseFollowUps below). A short usage-limit notice the client wrote itself (a synthetic message) is none with USAGE_LIMIT_REASON, the notice and any recorded resetsAt; isUsageLimit tells that result apart. The same words from the reviewer are malformed.
  • parseFollowUps(text): the optional Follow-ups: section, one - <title> | <done_when> | <severity> line per task directly under the heading, severity low, medium or high, at most MAX_FOLLOW_UPS. It may sit before or after the verdict lines; fenced or commented lines are not read (session-read's visibleLines). Follow-ups: none proposes nothing. Any fault drops the whole block with one warning from FOLLOW_UPS_WARNINGS (multiple_blocks, bad_item, bad_severity, too_many, too_long, placeholder), so a block is never filed in part. followUpsFields gives the two optional fields acceptVerdict adds, and FOLLOW_UPS_RULE is the sentence reviewerBrief places before the verdict lines.
  • aggregate(plan, results, input): the pure panel verdict from the members' results (MemberResult: shape, accepted verdict or none or timeout, degraded at spawn). Only a verdict of exactly MERGE or FIX_FIRST naming input.head counts; any other value is a missing member. Any blocking FIX_FIRST makes it FIX_FIRST and lists that member in dissent; MERGE needs every blocking member's MERGE. A blocking member with no verdict is no-verdict (timeout when every such member timed out), and a plan with no blocking member is no-verdict. Advisory members never block; their FIX_FIRST findings ride along with blocking: false, and each finding that cites no path:line existing in input.source (checked with evidence) 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. satisfiesG10 needs a g10 class, a MERGE, no degraded member, the correctness member at an opus profile (a key of sonnetFor) and the adversary's MERGE.
  • reviewPullRequest(request, ports, opts?): the one entry point. It classifies the PR, plans the panel (asking ports.headroom() once), runs every member in parallel and returns aggregate's verdict. Each member is spawned under memberName(target, shape) (rp-<owner>-<repo>-<pr>-<head 8>-<shape>), with its planned profile in ReviewerFacts.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 a none with SPAWN_REFUSED_REASON. The wait polls ports.reader through acceptVerdict. A malformed reply gets one correctionPrompt per member, sent by resume; silence past the deadline or an uncorrected reply gets one fresh reviewer under the -r2 name, and a member still silent after it is a timeout. 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).

ts
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 () => [],
};
ts
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 blocking
ts
import { 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 variant

What 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. reviewPullRequest holds nothing between calls; its idempotence comes from the member names.

Gotchas ​

  • ReviewerAgent.presence is a closed union. Map any unlisted roster value to unknown, which nothing treats as exited, detached or deregistered.
  • ReviewerAgent.predecessor and fillTokens absent mean unknown, and Shepherd never resumes such an agent. Report them when your roster knows them.
  • ReviewerReader.read returns messages oldest first; the last one is the final message.
  • PanelPlan.spendEstimate is 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 sonnetFor maps it to a sonnet profile; an opus profile missing from that map is never degraded or capped.
  • A shape brief's hash changes 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/v1 line. A line that does not parse, or names another head, is ignored.
  • reviewerBrief's fixProofUnverified (error, untrusted or unread) 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.
  • reviewerBrief asks every FIX_FIRST for a Defect class: section, first review included. Its earlierFindings (the run's earlier FIX_FIRSTs, oldest first) render between EARLIER-FINDINGS and END-EARLIER-FINDINGS, cut from the oldest end at MAX_FIX_FIRST_TEXT_CHARS and 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 an Earlier findings: checklist of - fixed: and - not fixed: lines, and for an earlier Defect class: line copied exactly when the class recurs.
  • reviewerBrief asks every MERGE, on any diff, for a CI-policy: unchanged or CI-policy: weakened <keys> line directly after Head: (CI_POLICY_RULE, which gives each key a one-line meaning). correctionPrompt repeats it with the key names only, since it resumes the session that already read the brief, and stays within MAX_CORRECTION_PROMPT_CHARS.
  • tests is advisory in the plan. aggregate makes it block when input.fixProof is vacuous, no-tests or error, and then its MERGE is needed too. When the plan has no tests member, that seat counts as missing, so the panel never says MERGE.
  • aggregate reads sonnetFor from its input, not the plan. Pass the same table you gave planPanel, or satisfiesG10 stays 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.

MIT licensed