PR description as data
Everything a reader needs about a change — the PR title, TL;DR, what & why, the hunk-anchored Tour, decisions, risks, validation criteria with their proofs — is one typed object, PrDescription, and every surface that shows it (the GitHub PR body today; the run page's review panel next; whatever replaces GitHub after that) is a renderer over that object. There is never a second copy of the content to keep in sync, and because Tour anchors are stored as (path, from, to) and rendered against the PR head at render time, regenerating a body after a repush is a re-render, not a rewrite.
This is the data half of the coding agent's PR description (agent-coding.md item 3 describes the submission contract as the prompt states it); item 5 below is the pipeline that turns the submitted object into the PR. It was motivated by a PR body nobody could consume and shaped on Ramp Inspect's Tour, with the step shape reversed to reader-first.
- Code:
src/core/prDescription.ts(PrDescriptionSchema/parsePrDescription,renderPrDescriptionMarkdown,anchorUrl,parsePrDescriptionMarkdown,redactPrDescription) with the zod-free shapes insrc/core/prDescriptionTypes.ts(RenderedTourStep);src/tools/workspace.ts(submit_pr_description);src/execution/githubPulls.ts(findOpenPrByHead/openPullRequest/updatePullRequest); the coding PR post-step insrc/core/codingPrPostStep.ts(trackPushedBranch/pushedBranchOf,observeCodingWorkspace,runCodingPrPostStep), wired fromsrc/core/dispatch/runLoop.tsandsrc/core/shipPipeline.ts; the description turn insrc/core/descriptionTurn.ts(item 5);scripts/render-pr-description.ts; fixture + goldensrc/core/testing/goldenTour.description.json/goldenTour.body.md. - Tests:
src/core/prDescription.test.ts;src/tools/workspace.test.ts(submit_pr_description tool);src/execution/githubPulls.test.ts;src/core/dispatcher.test.ts(coding PR post-step).
Behavior
The object.
PrDescription = { title, tldr, whatWhy, tour: TourStep[], remaining: {path, note}[], decisions: {title, rationale}[], risks, validation: { summary?, criteria: {criterion, proof}[] } }.titleis the PR title's single source — metadata for the PR's own title field, never rendered into the body. ATourStepis{ title, description, lookFor?, anchor: { path, from, to } }— the heading naming the change, the explanation, an optional pointer at what to scrutinize, and the hunk. Every text field is non-empty after trimming;tour,decisionsandcriterianeed at least one entry; an anchor'spathis repo-relative (no leading/, no..), lines are positive withto ≥ from.parsePrDescription(unknown)is the one validating entry point (zod; a violation throws naming the path) — thesubmit_pr_descriptiontool and any JSON file go through it.The sha is a render-time input.
renderPrDescriptionMarkdown(desc, { repo, headSha })refuses anything butowner/nameand a full 40-char lowercase sha: GitHub embeds ablob/<sha>/<path>#L<a>-L<b>link as a code block only for a commit ref — a branch name renders as a bare link. Because the object holds no sha, re-rendering at a new head is the whole repush story.The GitHub rendering. Sections in fixed order, each a
##heading,## TL;DRfirst: TL;DR · What & why · Tour · Decisions · Risks & implications · Validation. Tour steps render as### N. <title>, the description,**Look for:** …when present, then the permalink last (the reader knows what they are looking at before the code appears). A final### N+1. Remaining changeslists every uncovered file as- `path` — note(or one line saying none is left). Decisions render- **Title.** rationale(exactly one trailing period on the title). Validation is the optional summary line then a| Criterion | Proof |table with|escaped and newlines flattened inside cells. The body ends with the generated-with footer.The golden is a real PR.
src/core/testing/goldenTour.description.jsonis a real pull request's description as data (the one that introduced this pipeline);goldenTour.body.mdis its rendering at that PR's head; the suite asserts they are byte-identical, and that file is whatgh pr edit --body-fileput on the PR — the PR body, the golden and the test are one artifact by construction.scripts/render-pr-description.ts <json> --repo <owner/name> --head <sha>prints the rendering for the operator path.The coding agent submits the object; the system renders it and ships the PR.
submit_pr_description(fulltoolset only — readonly/web/none never carry it) validates withparsePrDescription; a violation comes back as a string error naming the zod path (never a throw) so the model fixes the object and retries within its own budget, and the last valid call wins — a resubmit after a fix-up push supersedes the earlier one. The PR's head branch is the branch the run's owngit pushnamed, read off the run's bash calls and results as they stream by (trackPushedBranchover the run events: only the result of a bash call whose command invokedgit push, paired bycallId, is read — a transcript printed by another command is not a push; the command is judged on the event's FULLcommandfield, which the runner puts on every bashtool_call(redacted,COMMAND_CAP4000 chars), because the 200-charsummaryroutinely loses a chainedcheckout && add && commit && push'spushpast the cap — a single chained command does exactly that, and judged on the summary the post-step would fall back to the checkout; an older record withoutcommandis judged on the summary, where that shape is a known false negative;pushedBranchOfparses that result: git'sTo <url>header followed by per-ref status lines —* [new branch] feat/x -> feat/x,abc..def feat/x -> feat/x, a forced+update, an= [up to date]answer; the block ends at the first line that is not a status line; rejections, deletions and tags name nothing;git fetch's look-alike block sits underFromand never counts; the last push wins) — the checked-out branch stands in only when no push was observed. The checkout is not the record of what was pushed: anything that moves HEAD between the push and the post — a second run'sgit checkout -bin a shared sandbox, the agent itself checking out another branch after pushing — would otherwise make the post ask the remote for a branch nobody pushed and orphan the pushed work. After the run — before the workspace is released (a resident re-attach would show the ref's tip, not what the run pushed) — the dispatcher observes the workspace: HEAD (git rev-parse HEAD), the checked-out branch (--abbrev-ref HEAD), the head branch's upstream (@{u}, or<branch>@{u}when a push named the branch) and, when a push named the branch, that branch's local tip (git rev-parse refs/heads/<branch>) concurrently, then the remote's own head for the head branch (git ls-remote --exit-code origin refs/heads/<branch>, through the same executor — and so the same credential — the agent's push used); on the cold path a root probe that fails discovers the single cloned subdirectory and re-probes there, and when the dispatch resolved no repo slug theowner/namecomes from the workspace's own origin remote (GitHub URL forms only). The observed head is the head branch's tip — HEAD when it is checked out, the localrefs/heads/<branch>when HEAD moved on (never HEAD then: that is another branch's commit); a pushed branch whose local ref is gone too has no observable head. A branch counts as pushed only when the remote'srefs/heads/<branch>equals that observed head — pushed is an observation of the remote, never an inference from names or from the clone's tracking state: a--depth/--single-branchclone (the cold sandbox's usualgh repo clone … -- --depth N) never creates the remote-tracking ref for a pushed branch, so@{u}fails after a successfulgit push -u; a remote that answers "no such branch" (exit 2) is final, and@{u}— the local record of the last push — stands in only when the remote probe itself failed (network, auth, an origin the thread user cannot read). The body renders at the observed 40-char head and the PR opens in the bot process over GitHub REST (src/execution/githubPulls.ts, App installation token, never aghshell-out) — open-or-edit: the open PR for the head branch is always looked up first and edited when it exists, so a repush re-renders and edits, never duplicates. Inputs are typed end to end:titlefrom the validated object, head from the observed head branch, base from the PR's true base ref when the thread's context came from a PR (a fix round repushes the PR's own head branch, whose binding ref is not the merge base), else the thread's resident binding ref, else the dispatch's resolved ref — prose can alter none of them. The render + open happen before the run's stream finishes: the accepted object is published as a typedpr_descriptionevent (every string leaf redacted) and the outcome as a typedpr_openedevent (url, number, created), so the PR facts live in the run record, not only in the reply. The reply carries the PR URL (opened vs. updated wording). A repush re-renders the title too — the object'stitleis the single source, so a hand-edited PR title is overwritten by the next submit. Failure honesty: a proven-pushed branch with no valid description first asks GitHub whether that branch already heads an open PR (the samestate=open+head=owner:branchlookup open-or-edit starts with) — a follow-up on an existing PR repushes the PR's own head branch, and the push itself updated the PR — and when it does, the note names that PR as updated by the push and flags, as a warning, that its description was not resubmitted (title and body untouched, since there is no description to render — and agent-coding.md item 3 requires a resubmit after every push to an existing PR, so this is a defect of the run, never an accepted outcome) and the record carriespr_openedwithcreated: false; only a branch with no open PR (or a lookup that failed — logged, never thrown) gets the branch compare URL and the plain "no PR was opened" statement. The description turn (src/core/descriptionTurn.ts) enforces the rule before the note is ever needed: when a coding run's model loop ends with no submitted description, a push the remote proves onto a branch that is not the base, and an open PR heading that branch (the same lookup; a failed lookup means no turn), the dispatcher — and a ship coding round for the pipeline's own PR (agent-ship.md item 7), which runs the identical turn on a copy of the round's messages — after the model is done, before the answer lands or the workspace is released, never after a hard stop, publishes adescription_turnrun note and runs ONE more model turn on the run's own messages (the seed, the run's answer, a follow-up naming the PR, the pushed head,github_issue_getas the way to read the current title and body, the three steps, and the two prohibitions: no further push, no PR opened) on a clipped copy of the coding def (at most 8 turns / 5 minutes, never the shared AgentDef), with the same tool context — so the description arrives through the samesubmit_pr_descriptionhook and the ledger row sees it — under arun.description_turnspan; the workspace is observed again afterwards, and the post-step then opens-or-edits as for any submitted description ("PR updated: … body re-rendered at<sha>"). A turn that still submits nothing leaves the warning standing, worded to say the turn was given ("even in the dedicated description turn this run was given"); a turn that fails is logged and reported the same way. The turn's tool events join the run record like any other; its steps are not ledgered (a kill mid-turn resumes the run from the main loop's transcript, as the re-review turn does); a failed render/open is reported with the branch compare URL and the plain reason; an unproven push (the branch not on the remote, unpushed commits, unobservable head) gets an honest note and NO compare URL — a URL implying a remote branch that may not exist is a fabrication; when the pushed branch and the checkout disagree, the note names both (the branch the push named, and the branch the workspace was checked out on) so the reader knows which branch was asked about; a workspace still on the base branch pushed nothing, so no PR call and no note beyond the agent's own report.The inverse: a rendered body back into the object.
parsePrDescriptionMarkdown(body)→{ description: Partial<PrDescription> & { tour: RenderedTourStep[] }, complete, problems }reads a body by the contract item 3 writes: sections by their fixed##headings (case-insensitive; a##inside a fence is code; a duplicate section keeps the first and is a problem; an unknown section is skipped), Tour steps by### N. title+ prose + optional**Look for:**(to the next blank line) + the bare permalinkhttps://github.com/<owner>/<repo>/blob/<sha>/<path>#L<a>[-L<b>]— the path percent-decoded, the anchor validated byTourAnchorSchema, the permalink's sha riding on the anchor asRenderedTourStep.anchor.shaso a reader can tell whether it is at the head being looked at — theRemaining changesstep by its- `path` — notelines (continuation lines join the note; the none line is an empty list), decisions by- **Title.** rationale, validation by the optional summary and the| Criterion | Proof |table with\|/\\unescaped. Strict where the renderer is strict, lenient where a human would be, never a throw: a step without a bare github permalink, with a schema-invalid anchor (absolute or traversing path,to < from) or a malformed percent escape is a problem and is dropped; a 7–40-hex sha and a single-line#L<a>are accepted; a parsed step may carry an empty description (a heading + permalink); a body without## TL;DRtakes the first paragraph that is not a heading astldr; every missing section is a problem;complete⇔problemsis empty. Round trip on the golden:tldr,whatWhy,tour(plus each anchor's sha = the render head),remaining,decisions,risksandvalidationcome back equal. Lossy by construction: the PR title is not in the body; a decision title that ended in a period loses it (the renderer's normalization); a newline inside a validation cell was flattened to a space when rendered. This is what a review run publishes for a PR no coding run described (reading-diff.md item 7).
Roadmap (gaps)
None open. The panel renderer is reading-diff.md item 12: the run page renders the same object beside the reading diff from the review_artifact pr_description every PR review carries (item 7) — never a second authoring path.
Validation criteria
| Criterion | Proof |
|---|---|
| Schema: complete object accepted, lines trimmed; empty tour / decisions / criteria, missing sections and blank strings rejected naming the field | [unit] src/core/prDescription.test.ts::parsePrDescription (the schema) (2) |
Anchors: absolute or traversing path, non-positive line, to < from rejected | [unit] ::rejects a bad anchor… |
Every section a ## heading in contract order, TL;DR first, footer last | [unit] ::renders every section as a ## heading… |
Step shape: ### N. title → description → optional Look for → permalink last; numbered from 1; Remaining changes is step N+1 | [unit] ::a Tour step is … |
Sha is a render input: same object, different head → different anchors; anchorUrl shape | [unit] ::anchors take the sha from the render context… |
Short sha / branch ref / non owner/name refused | [unit] ::refuses a short sha or a non owner\/name repo… |
| Remaining changes: per-file lines, or the none line | [unit] ::Remaining changes lists each file… |
| Decisions punctuation; validation summary + table; cell escaping | [unit] ::decisions read … |
| The golden PR's description renders byte-for-byte to the checked-in body, which is the PR's live body | [unit] ::golden: PR … rendered through the pipeline. [agent] In this repository, gh pr view 329 --json body --jq .body equals src/core/testing/goldenTour.body.md (modulo GitHub's trailing-newline normalization) |
| The golden description renders byte-for-byte to the checked-in body | [unit] ::golden: a full PR description rendered through the pipeline |
Tool: valid object reaches the hook parsed and trimmed; last valid call wins and an invalid call leaves the valid one standing; schema violations are string errors naming the path (missing section, blank title, bad anchor); full toolset only | [unit] src/tools/workspace.test.ts::submit_pr_description tool (10) |
Open-or-edit idempotency: lookup state=open + head=owner:branch first, create only when absent; typed title/head/base — prose in the body cannot alter them; missing credential or non-2xx → throw with detail; oversized body clipped visibly | [unit] src/execution/githubPulls.test.ts::githubPulls (9) |
Dispatcher renders at the observed head and opens from typed values: the observed head branch as head, base from binding ref (else resolved ref), title from the object; reply carries the returned URL | [unit] src/core/dispatcher.test.ts::description submitted + head observed → the PR opens from typed values…, ::no resident binding → base falls back to the dispatch's resolved ref |
| The head branch is the branch the run's push named: HEAD moved to another branch after the push → the PR still opens from the PUSHED branch, the body rendered at that branch's local tip (never the checkout's commit); no push observed → the checkout is the head branch exactly as before; the pushed branch gone from the remote while the checkout moved on → the honest note names BOTH branches, no PR call, no compare URL; the pushed branch's local ref gone too → "could not be observed" naming both | [unit] src/core/dispatcher.test.ts::HEAD moved to another branch after the push …, ::the pushed branch is gone from the remote while the checkout moved on…, ::no push observed in the run → the checkout is the head branch…; src/core/codingPrPostStep.test.ts::HEAD moved to another branch after the push …, ::the pushed branch is not on the remote while the workspace sits on another…, ::the pushed branch has no local tip to observe…; ::observeCodingWorkspace::a push named another branch than the checkout …, ::no push observed → the checkout IS the head branch…, ::the pushed branch is the checkout too…, ::the pushed branch has no local ref any more…, ::a pushed branch name with shell metacharacters reaches every probe quoted |
pushedBranchOf reads only git's push status block, and trackPushedBranch reads it only from the result of a bash call whose command invoked git push (paired by callId — a cat/echo of a transcript, or a result with no paired call, names nothing; the latest push wins and a later non-push result never erases it): new-branch, fast-forward, forced and up-to-date lines name the remote branch (HEAD:feat/x names the remote side; a redacted credential in the To URL still counts); rejections, deletions and tags name nothing (a deletion line stays inside the block, so a mixed push still names its updated branch); a git fetch block (From) and a status-shaped line with no To header never count; the block ends at the first non-status line; the last push wins; only bash results with output are read; the push command is recognized on the event's full command (a chained command whose push sits past the 200-char summary cap still counts), and on the capped summary alone for an event without command (where it is missed) | [unit] src/core/codingPrPostStep.test.ts::pushedBranchOf (the branch a run's own git push named) (12), ::trackPushedBranch (paired with the command that produced the output) (5, incl. ::a chained command whose \git push` sits past the 200-char summary cap…); src/core/dispatcher.test.ts::a push block printed by …cat push.log (not a git push) is not a push…` |
| A thread bound to an existing PR's head branch uses the PR's TRUE base ref — a fix-round repush edits instead of reading as "nothing pushed" | [unit] src/core/dispatcher.test.ts::a thread bound to an existing PR's head branch: the base is the PR's TRUE base ref… |
| Repush → the existing open PR is edited, never duplicated; the reply says updated, not opened | [unit] src/core/dispatcher.test.ts::an existing open PR is edited (open-or-edit): created:false → the reply says updated, not opened |
Pushed is proven by the remote's own refs/heads/<branch>: a single-branch clone whose @{u} fails after a real push still opens its PR; a push without -u too; the remote holding an older commit → unpushed commits; exit 2 ("no such branch") is final even over a stale @{u}; a failed remote probe falls back to @{u}; only the exact ref counts — an exit-0 answer of look-alike refs is "absent" (final), an exit-0 answer with no ref line is a failed probe; a detached checkout asks the remote nothing; the branch ref reaches the shell quoted | [unit] src/core/codingPrPostStep.test.ts::observeCodingWorkspace::single-branch clone …, ::no upstream configured at all…, ::the remote holds an older commit…, ::the remote answers 'no such branch' (exit 2)…, ::the remote probe itself fails…, ::only the exact ref counts…, ::an exit-0 answer with no ref line…, ::a detached checkout…, ::a branch name with shell metacharacters… |
| Not pushed → no PR call, honest note without a compare URL: the branch not on the remote ("was not found on the remote"), the remote branch behind HEAD ("unpushed commits", both commits named), or an unobservable head | [unit] src/core/codingPrPostStep.test.ts::the branch is not on the remote at all…, ::an unproven push (the remote branch behind the workspace)…; src/core/dispatcher.test.ts::the remote has no such branch → the branch does not count as pushed…, ::the remote branch behind the workspace HEAD → not pushed…, ::description submitted but the pushed head is unobservable… |
| Cold path: a clone in a subdirectory is discovered and probed; no git anywhere → honest note; an agent-discovered repo's slug comes from the origin remote, an unparseable remote refuses honestly | [unit] src/core/dispatcher.test.ts::cold path: the clone lives in a subdirectory…, ::no git repository anywhere in the workspace…, ::dispatch resolved no repo slug (agent-discovered repo)…, ::no repo anywhere — dispatch resolved none and the origin remote is unparseable… |
Failure honesty: proven-pushed branch + no description → the open-PR lookup first: an open PR heading the branch → the reply names it as updated by the push and warns that the description was not resubmitted (no compare URL, no PR call, pr_opened created:false in the record); none, or a failed lookup → compare URL + plain statement, no PR call; open throws → error + compare URL, run still completes; nothing pushed → no call and no note | [unit] src/core/codingPrPostStep.test.ts::no description + proven push + the branch heads an open PR…, ::no description + proven push + no open PR heads the branch…, ::no description + proven push, the open-PR lookup throws…, ::the open-PR lookup is asked ONLY for a description-less proven push…; src/core/dispatcher.test.ts::no description submitted but a pushed branch is observable…, ::no description submitted, the pushed branch already heads an open PR…, ::no description submitted and the open-PR lookup fails…, ::openPullRequest throws…, ::the workspace sat on the base branch…, ::description submitted but no branch is observable… |
The description turn: due only for no description + a push the remote proves + a branch that is not the base + an open PR heading it (a failed lookup → no turn); the follow-up names the PR, the pushed head, github_issue_get and its arguments, submit_pr_description, and forbids a further push or opening a PR; the turn publishes the description_turn note, appends the answer + follow-up in place, runs a clipped COPY of the coding def (8 turns / 5 min) with the same tools, and reports the description the hook received (none on a bare answer or a runner failure, never a throw); end to end, the turn's description edits the PR with the updated wording and pr_description + pr_opened + the note land in the record; a turn that submits nothing leaves the ⚠️ note naming the turn; no turn when a description was submitted, the push is unproven, or no open PR heads the branch | [unit] src/core/descriptionTurn.test.ts::descriptionTurnTarget — when a description turn is due (6), ::descriptionFollowUp — the user turn the model is given, ::runDescriptionTurn — one clipped turn on the run's own messages (2); src/core/dispatcher.test.ts::a description-less push onto a branch that heads an open PR → ONE description turn…, ::the description turn still submits nothing → the ⚠️ note says the turn was given…, ::no description turn when a description was submitted, when the push is unproven, or when no open PR heads the branch, ::a ship coding round that pushed without a description gets the description turn… |
Readonly runs never trigger the post-step; observation happens before workspace release; the accepted object rides a pr_description event with EVERY string leaf redacted; the outcome rides a pr_opened event published before the stream finishes | [unit] src/core/dispatcher.test.ts::a readonly review run with a verdict never triggers the PR post-step, ::the head and branch are observed BEFORE the workspace is released, ::the accepted description is published on the run stream as a typed pr_description event, redacted, ::redaction walks every string leaf…, ::the PR outcome is a fact of the run: a typed pr_opened event… |
| 6: the golden renders and parses back equal — tldr, whatWhy, tour with each anchor's render sha, remaining, decisions, risks, validation; the title is not in the body; a small description round-trips (empty Remaining, multi-paragraph description, single-line anchor, cell unescaping) with the decision's trailing period as the lossy part | [unit] src/core/prDescription.test.ts::parsePrDescriptionMarkdown — the inverse of the renderer::golden round trip…, ::a small description round-trips too… |
6: a body without the shape never throws — first paragraph as tldr, empty tour, complete: false, problems naming the missing sections; the house shape without ## TL;DR / Risks (CRLF) parses its Tour and names exactly the missing sections | [unit] ::a body without the shape never throws…, ::a hand-written body in the house shape… |
6: step strictness — no bare github permalink, a non-github link, a traversing path or to < from is a problem and dropped; a short sha and #L<a> accepted; a ## inside a fence is not a section; a percent-encoded path is decoded, a malformed escape is a problem; a duplicate section keeps the first; an unrecognized Remaining line is a problem | [unit] ::step strictness…, ::a percent-encoded path is decoded… |
Panel renderer: the run page's review panel renders the object — TL;DR, Tour, Remaining — from the review's pr_description artifact | [unit] web/src/modules/pr-review/prReview.test.ts::the PR description in the panel::*, web/src/pages/runPage.test.ts::PR-review panel wiring::a seeded pr_description renders in the panel… |